diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index 3fd28305368e..02a3122327a2 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -14,6 +14,7 @@ export namespace Auth { expires: z.number(), accountId: z.string().optional(), enterpriseUrl: z.string().optional(), + label: z.string().optional(), }) .meta({ ref: "OAuth" }) @@ -21,6 +22,7 @@ export namespace Auth { .object({ type: z.literal("api"), key: z.string(), + label: z.string().optional(), }) .meta({ ref: "ApiAuth" }) @@ -29,6 +31,7 @@ export namespace Auth { type: z.literal("wellknown"), key: z.string(), token: z.string(), + label: z.string().optional(), }) .meta({ ref: "WellKnownAuth" }) @@ -70,4 +73,34 @@ export namespace Auth { await Bun.write(file, JSON.stringify(data, null, 2)) await fs.chmod(file.name!, 0o600) } + + export function parseProviderKey(key: string): { base: string; alias?: string } { + const parts = key.split(":") + if (parts.length === 1) return { base: key } + return { base: parts[0], alias: parts.slice(1).join(":") } + } + + export function createProviderKey(base: string, alias?: string): string { + if (!alias) return base + return `${base}:${alias}` + } + + export async function listForProvider(providerID: string): Promise> { + const data = await all() + const result: Record = {} + for (const [key, value] of Object.entries(data)) { + const parsed = parseProviderKey(key) + if (parsed.base === providerID) { + result[key] = value + } + } + return result + } + + export async function generateAlias(base: string): Promise { + const existing = await listForProvider(base) + const count = Object.keys(existing).length + if (count === 0) return base + return createProviderKey(base, String(count + 1)) + } } 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 4e1171a42017..467c7f3f5604 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -1,6 +1,6 @@ import { createMemo, createSignal, onMount, Show } from "solid-js" import { useSync } from "@tui/context/sync" -import { map, pipe, sortBy } from "remeda" +import { groupBy, map, pipe, sortBy } from "remeda" import { DialogSelect } from "@tui/ui/dialog-select" import { useDialog } from "@tui/ui/dialog" import { useSDK } from "../context/sdk" @@ -22,17 +22,47 @@ const PROVIDER_PRIORITY: Record = { google: 4, } +function getBaseProvider(id: string): string { + const parts = id.split(":") + return parts[0] +} + export function createDialogProviderOptions() { const sync = useSync() const dialog = useDialog() const sdk = useSDK() const connected = createMemo(() => new Set(sync.data.provider_next.connected)) + + // Group connected providers by base provider + const connectedByBase = createMemo(() => { + const result: Record = {} + for (const id of sync.data.provider_next.connected) { + const base = getBaseProvider(id) + if (!result[base]) result[base] = [] + result[base].push(id) + } + return result + }) + + // Get provider info by ID + const providerById = createMemo(() => { + const result: Record = {} + for (const p of sync.data.provider_next.all) { + result[p.id] = p + } + return result + }) + const options = createMemo(() => { + // Filter to only show base providers (not aliased ones) + const baseProviders = sync.data.provider_next.all.filter((p) => !p.base) + return pipe( - sync.data.provider_next.all, + baseProviders, sortBy((x) => PROVIDER_PRIORITY[x.id] ?? 99), map((provider) => { - const isConnected = connected().has(provider.id) + const connectedAccounts = connectedByBase()[provider.id] ?? [] + const isConnected = connectedAccounts.length > 0 return { title: provider.name, value: provider.id, @@ -42,62 +72,21 @@ export function createDialogProviderOptions() { openai: "(ChatGPT Plus/Pro or API key)", }[provider.id], category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other", - footer: isConnected ? "Connected" : undefined, + footer: isConnected ? `Connected (${connectedAccounts.length})` : 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((resolve) => { - dialog.replace( - () => ( - ({ - 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 result = await sdk.client.provider.oauth.authorize({ - providerID: provider.id, - method: index, - }) - if (result.data?.method === "code") { - dialog.replace(() => ( - - )) - } - if (result.data?.method === "auto") { - dialog.replace(() => ( - - )) - } - } - if (method.type === "api") { - return dialog.replace(() => ) + if (isConnected) { + // Show account management dialog + dialog.replace(() => ( + + )) + } else { + // Start normal connection flow + startConnectionFlow(provider.id, provider.name, dialog, sdk, sync) } }, } @@ -107,6 +96,134 @@ export function createDialogProviderOptions() { return options } +async function startConnectionFlow( + providerID: string, + providerName: string, + dialog: ReturnType, + sdk: ReturnType, + sync: ReturnType, + label?: string, +) { + const methods = sync.data.provider_auth[providerID] ?? [ + { + type: "api", + label: "API key", + }, + ] + let index: number | null = 0 + if (methods.length > 1) { + index = await new Promise((resolve) => { + dialog.replace( + () => ( + ({ + title: x.label, + value: idx, + }))} + onSelect={(option) => resolve(option.value)} + /> + ), + () => resolve(null), + ) + }) + } + if (index == null) return + const method = methods[index] + if (method.type === "oauth") { + const result = await sdk.client.provider.oauth.authorize({ + providerID, + method: index, + label, + }) + if (result.data?.method === "code") { + dialog.replace(() => ( + + )) + } + if (result.data?.method === "auto") { + dialog.replace(() => ( + + )) + } + } + if (method.type === "api") { + return dialog.replace(() => ) + } +} + +interface DialogProviderAccountsProps { + providerID: string + providerName: string + connectedAccounts: string[] + providerById: Record +} + +function DialogProviderAccounts(props: DialogProviderAccountsProps) { + const dialog = useDialog() + const sdk = useSDK() + const sync = useSync() + + const options = createMemo(() => { + const result: Array<{ + title: string + value: string + footer?: string + onSelect: () => void + }> = [] + + // Add "Add another account" option first + result.push({ + title: "Add another account", + value: "add", + async onSelect() { + // Prompt for label + dialog.replace(() => ( + This helps you identify which account to use} + onConfirm={async (label) => { + if (!label) return + startConnectionFlow(props.providerID, props.providerName, dialog, sdk, sync, label) + }} + /> + )) + }, + }) + + // Add existing accounts + for (const accountID of props.connectedAccounts) { + const provider = props.providerById[accountID] + const label = provider?.label ?? (accountID === props.providerID ? "Default" : accountID) + result.push({ + title: label, + value: accountID, + footer: "Connected", + onSelect() { + dialog.replace(() => ) + }, + }) + } + + return result + }) + + return +} + export function DialogProvider() { const options = createDialogProviderOptions() return @@ -117,6 +234,7 @@ interface AutoMethodProps { providerID: string title: string authorization: ProviderAuthAuthorization + label?: string } function AutoMethod(props: AutoMethodProps) { const { theme } = useTheme() @@ -138,6 +256,7 @@ function AutoMethod(props: AutoMethodProps) { const result = await sdk.client.provider.oauth.callback({ providerID: props.providerID, method: props.index, + label: props.label, }) if (result.error) { dialog.clear() @@ -145,7 +264,9 @@ function AutoMethod(props: AutoMethodProps) { } await sdk.client.instance.dispose() await sync.bootstrap() - dialog.replace(() => ) + // Use the actual provider ID that was created (may be aliased) + const targetProviderID = props.label ? `${props.providerID}:${props.label}` : props.providerID + dialog.replace(() => ) }) return ( @@ -173,6 +294,7 @@ interface CodeMethodProps { title: string providerID: string authorization: ProviderAuthAuthorization + label?: string } function CodeMethod(props: CodeMethodProps) { const { theme } = useTheme() @@ -190,11 +312,13 @@ function CodeMethod(props: CodeMethodProps) { providerID: props.providerID, method: props.index, code: value, + label: props.label, }) if (!error) { await sdk.client.instance.dispose() await sync.bootstrap() - dialog.replace(() => ) + const targetProviderID = props.label ? `${props.providerID}:${props.label}` : props.providerID + dialog.replace(() => ) return } setError(true) @@ -215,6 +339,7 @@ function CodeMethod(props: CodeMethodProps) { interface ApiMethodProps { providerID: string title: string + label?: string } function ApiMethod(props: ApiMethodProps) { const dialog = useDialog() @@ -240,16 +365,18 @@ function ApiMethod(props: ApiMethodProps) { } onConfirm={async (value) => { if (!value) return + const targetProviderID = props.label ? `${props.providerID}:${props.label}` : props.providerID await sdk.client.auth.set({ - providerID: props.providerID, + providerID: targetProviderID, auth: { type: "api", key: value, + label: props.label, }, }) await sdk.client.instance.dispose() await sync.bootstrap() - dialog.replace(() => ) + dialog.replace(() => ) }} /> ) diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts index e6681ff08914..7c3a5381c2d6 100644 --- a/packages/opencode/src/provider/auth.ts +++ b/packages/opencode/src/provider/auth.ts @@ -55,13 +55,19 @@ export namespace ProviderAuth { z.object({ providerID: z.string(), method: z.number(), + label: z.string().optional(), }), async (input): Promise => { 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() - await state().then((s) => (s.pending[input.providerID] = result)) + const pendingKey = input.label ? `${input.providerID}:${input.label}` : input.providerID + await state().then((s) => { + s.pending[pendingKey] = result + // Store label for use in callback + ;(result as any)._label = input.label + }) return { url: result.url, method: result.method, @@ -76,9 +82,11 @@ export namespace ProviderAuth { providerID: z.string(), method: z.number(), code: z.string().optional(), + label: z.string().optional(), }), async (input) => { - const match = await state().then((s) => s.pending[input.providerID]) + const pendingKey = input.label ? `${input.providerID}:${input.label}` : input.providerID + const match = await state().then((s) => s.pending[pendingKey]) if (!match) throw new OauthMissing({ providerID: input.providerID }) let result @@ -91,11 +99,15 @@ export namespace ProviderAuth { result = await match.callback() } + // Determine the auth key - use aliased key if label provided + const authKey = input.label ? `${input.providerID}:${input.label}` : input.providerID + if (result?.type === "success") { if ("key" in result) { - await Auth.set(input.providerID, { + await Auth.set(authKey, { type: "api", key: result.key, + label: input.label, }) } if ("refresh" in result) { @@ -104,11 +116,12 @@ export namespace ProviderAuth { access: result.access, refresh: result.refresh, expires: result.expires, + label: input.label, } if (result.accountId) { info.accountId = result.accountId } - await Auth.set(input.providerID, info) + await Auth.set(authKey, info) } return } diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index c5465f9880ed..e0146660d655 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -72,6 +72,8 @@ export namespace ModelsDev { id: z.string(), npm: z.string().optional(), models: z.record(z.string(), Model), + label: z.string().optional(), + base: z.string().optional(), }) export type Provider = z.infer diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index fdd4ccdfb619..27bf77238d4c 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -586,6 +586,8 @@ export namespace Provider { key: z.string().optional(), options: z.record(z.string(), z.any()), models: z.record(z.string(), Model), + label: z.string().optional(), + base: z.string().optional(), }) .meta({ ref: "Provider", @@ -683,7 +685,11 @@ export namespace Provider { const enabled = config.enabled_providers ? new Set(config.enabled_providers) : null function isProviderAllowed(providerID: string): boolean { - if (enabled && !enabled.has(providerID)) return false + const parsed = Auth.parseProviderKey(providerID) + const checkID = parsed.alias ? parsed.base : providerID + + if (enabled && !enabled.has(checkID)) return false + if (disabled.has(checkID)) return false if (disabled.has(providerID)) return false return true } @@ -821,47 +827,105 @@ export namespace Provider { }) } - // load apikeys - for (const [providerID, provider] of Object.entries(await Auth.all())) { - if (disabled.has(providerID)) continue - if (provider.type === "api") { - mergeProvider(providerID, { - source: "api", - key: provider.key, - }) + // load apikeys (including aliased providers like "openai:work") + for (const [authKey, authInfo] of Object.entries(await Auth.all())) { + if (disabled.has(authKey)) continue + const parsed = Auth.parseProviderKey(authKey) + const baseProviderID = parsed.base + + if (authInfo.type === "api") { + if (parsed.alias) { + // This is an aliased provider (e.g., "openai:work") + const baseProvider = database[baseProviderID] + if (!baseProvider) continue + + // Create aliased provider inheriting from base + const aliasedProvider: Info = { + ...baseProvider, + id: authKey, + name: authInfo.label ? `${baseProvider.name} (${authInfo.label})` : baseProvider.name, + source: "api", + key: authInfo.key, + label: authInfo.label, + base: baseProviderID, + models: mapValues(baseProvider.models, (model) => ({ + ...model, + providerID: authKey, + })), + } + providers[authKey] = aliasedProvider + } else { + mergeProvider(authKey, { + source: "api", + key: authInfo.key, + label: authInfo.label, + }) + } } } for (const plugin of await Plugin.list()) { if (!plugin.auth) continue - const providerID = plugin.auth.provider - if (disabled.has(providerID)) continue - - // For github-copilot plugin, check if auth exists for either github-copilot or github-copilot-enterprise - let hasAuth = false - const auth = await Auth.get(providerID) - if (auth) hasAuth = true + const baseProviderID = plugin.auth.provider + if (disabled.has(baseProviderID)) continue + + // Get all auth entries for this provider (including aliases like "github-copilot:work") + const allAuth = await Auth.all() + const relevantAuth = Object.entries(allAuth).filter(([key]) => { + const parsed = Auth.parseProviderKey(key) + return parsed.base === baseProviderID + }) - // Special handling for github-copilot: also check for enterprise auth - if (providerID === "github-copilot" && !hasAuth) { - const enterpriseAuth = await Auth.get("github-copilot-enterprise") - if (enterpriseAuth) hasAuth = true + if (relevantAuth.length === 0) { + // Special handling for github-copilot: also check for enterprise auth + if (baseProviderID === "github-copilot") { + const enterpriseAuth = await Auth.get("github-copilot-enterprise") + if (!enterpriseAuth) continue + } else { + continue + } } - if (!hasAuth) continue if (!plugin.auth.loader) continue - // Load for the main provider if auth exists - if (auth) { - const options = await plugin.auth.loader(() => Auth.get(providerID) as any, database[plugin.auth.provider]) - mergeProvider(plugin.auth.provider, { - source: "custom", - options: options, - }) + // Load each aliased provider + for (const [authKey, authInfo] of relevantAuth) { + if (disabled.has(authKey)) continue + const parsed = Auth.parseProviderKey(authKey) + + if (parsed.alias) { + // Aliased OAuth provider (e.g., "github-copilot:work") + const baseProvider = database[baseProviderID] + if (!baseProvider) continue + + const options = await plugin.auth.loader(() => Auth.get(authKey) as any, baseProvider) + const aliasedProvider: Info = { + ...baseProvider, + id: authKey, + name: authInfo.label ? `${baseProvider.name} (${authInfo.label})` : baseProvider.name, + source: "custom", + options, + label: authInfo.label, + base: baseProviderID, + models: mapValues(baseProvider.models, (model) => ({ + ...model, + providerID: authKey, + })), + } + providers[authKey] = aliasedProvider + } else { + // Main provider (e.g., "github-copilot") + const options = await plugin.auth.loader(() => Auth.get(authKey) as any, database[plugin.auth.provider]) + mergeProvider(plugin.auth.provider, { + source: "custom", + options, + label: authInfo.label, + }) + } } // If this is github-copilot plugin, also register for github-copilot-enterprise if auth exists - if (providerID === "github-copilot") { + if (baseProviderID === "github-copilot") { const enterpriseProviderID = "github-copilot-enterprise" if (!disabled.has(enterpriseProviderID)) { const enterpriseAuth = await Auth.get(enterpriseProviderID) @@ -894,6 +958,19 @@ export namespace Provider { options: result.options, }) } + + // Also apply custom loader to aliased providers + for (const aliasedID of Object.keys(providers)) { + const aliased = providers[aliasedID] + if (aliased.base !== providerID) continue + if (result?.getModel) modelLoaders[aliasedID] = result.getModel + if (result?.options) { + providers[aliasedID] = { + ...aliased, + options: mergeDeep(aliased.options, result.options), + } + } + } } // load config @@ -977,7 +1054,7 @@ export namespace Provider { ...model.headers, } - const key = Bun.hash.xxHash32(JSON.stringify({ npm: model.api.npm, options })) + const key = Bun.hash.xxHash32(JSON.stringify({ npm: model.api.npm, providerID: model.providerID, options })) const existing = s.sdk.get(key) if (existing) return existing diff --git a/packages/opencode/src/server/routes/provider.ts b/packages/opencode/src/server/routes/provider.ts index 872b48be79dc..a54b174e65a6 100644 --- a/packages/opencode/src/server/routes/provider.ts +++ b/packages/opencode/src/server/routes/provider.ts @@ -108,14 +108,16 @@ export const ProviderRoutes = lazy(() => "json", z.object({ method: z.number().meta({ description: "Auth method index" }), + label: z.string().optional().meta({ description: "Account label for multi-account support" }), }), ), async (c) => { const providerID = c.req.valid("param").providerID - const { method } = c.req.valid("json") + const { method, label } = c.req.valid("json") const result = await ProviderAuth.authorize({ providerID, method, + label, }) return c.json(result) }, @@ -149,15 +151,17 @@ export const ProviderRoutes = lazy(() => z.object({ method: z.number().meta({ description: "Auth method index" }), code: z.string().optional().meta({ description: "OAuth authorization code" }), + label: z.string().optional().meta({ description: "Account label for multi-account support" }), }), ), async (c) => { const providerID = c.req.valid("param").providerID - const { method, code } = c.req.valid("json") + const { method, code, label } = c.req.valid("json") await ProviderAuth.callback({ providerID, method, code, + label, }) return c.json(true) }, diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 706d0f9c227d..f62c6723d2f8 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -1930,6 +1930,7 @@ export class Oauth extends HeyApiClient { providerID: string directory?: string method?: number + label?: string }, options?: Options, ) { @@ -1941,6 +1942,7 @@ export class Oauth extends HeyApiClient { { in: "path", key: "providerID" }, { in: "query", key: "directory" }, { in: "body", key: "method" }, + { in: "body", key: "label" }, ], }, ], @@ -1972,6 +1974,7 @@ export class Oauth extends HeyApiClient { directory?: string method?: number code?: string + label?: string }, options?: Options, ) { @@ -1984,6 +1987,7 @@ export class Oauth extends HeyApiClient { { in: "query", key: "directory" }, { in: "body", key: "method" }, { in: "body", key: "code" }, + { in: "body", key: "label" }, ], }, ], diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 8442889020fb..8f39d661abe7 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1482,6 +1482,8 @@ export type ProviderConfig = { } } } + label?: string + base?: string whitelist?: Array blacklist?: Array options?: { @@ -1886,6 +1888,8 @@ export type Provider = { models: { [key: string]: Model } + label?: string + base?: string } export type ToolIds = Array @@ -2123,17 +2127,20 @@ export type OAuth = { expires: number accountId?: string enterpriseUrl?: string + label?: string } export type ApiAuth = { type: "api" key: string + label?: string } export type WellKnownAuth = { type: "wellknown" key: string token: string + label?: string } export type Auth = OAuth | ApiAuth | WellKnownAuth @@ -3875,6 +3882,8 @@ export type ProviderListResponses = { } } } + label?: string + base?: string }> default: { [key: string]: string @@ -3911,6 +3920,10 @@ export type ProviderOauthAuthorizeData = { * Auth method index */ method: number + /** + * Account label for multi-account support + */ + label?: string } path: { /** @@ -3952,6 +3965,10 @@ export type ProviderOauthCallbackData = { * OAuth authorization code */ code?: string + /** + * Account label for multi-account support + */ + label?: string } path: { /**