diff --git a/packages/app/src/composer/commands.tsx b/packages/app/src/composer/commands.tsx index 4cafbffaf8e..17fd16e1e3d 100644 --- a/packages/app/src/composer/commands.tsx +++ b/packages/app/src/composer/commands.tsx @@ -5,6 +5,7 @@ import { useDialog } from "@opencode/ui/context/dialog" import { getCursorPosition, setCursorPosition } from "./editor/dom" import { useSessionLayout } from "@/session/session-layout" import { createSessionOwnership } from "@/session/session-ownership" +import { useWorkspaceLocation } from "@/workspaces/location" const withCategory = (category: string) => { return (option: Omit): CommandOption => ({ @@ -18,11 +19,19 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => { const dialog = useDialog() const language = useLanguage() const local = useLocal() + const workspace = useWorkspaceLocation() const { sessionKey } = useSessionLayout() const sessionOwnership = createSessionOwnership(sessionKey) const model = input.model ?? local.model const modelCommand = withCategory(language.t("command.category.model")) const agentCommand = withCategory(language.t("command.category.agent")) + const providerCommand = withCategory(language.t("command.category.provider")) + + // Mirrors the TUI's `/connect`, which the Console's setup steps tell people to run. + const connectProvider = async () => { + const { DialogConnectProvider } = await import("@/providers/connect/dialog") + void dialog.show(() => ) + } const chooseModel = async () => { const owner = sessionOwnership.capture() @@ -62,6 +71,13 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => { keybind: "shift+mod+d", onSelect: () => model.variant.cycle(), }), + providerCommand({ + id: "provider.connect", + title: language.t("command.provider.connect"), + description: language.t("command.provider.connect.description"), + slash: "connect", + onSelect: connectProvider, + }), agentCommand({ id: "agent.cycle", title: language.t("command.agent.cycle"), diff --git a/packages/app/src/providers/connect/controller.ts b/packages/app/src/providers/connect/controller.ts index b2138b68e20..b1553cc6e69 100644 --- a/packages/app/src/providers/connect/controller.ts +++ b/packages/app/src/providers/connect/controller.ts @@ -1,18 +1,44 @@ -import type { FormAnswer, IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode/client/promise" +import type { + FormAnswer, + IntegrationInfo, + IntegrationMethod, + IntegrationOauthConnectOutput, +} from "@opencode/client/promise" import { useLanguage } from "@/runtime/i18n/language" import { usePlatform } from "@/runtime/platform/platform" import { useServerSDK } from "@/runtime/server/client" import { useData } from "@/runtime/server/current" -import { createEffect, createMemo, createResource, onCleanup } from "solid-js" +import { createEffect, createMemo, on, onCleanup } from "solid-js" import { createStore, produce } from "solid-js/store" export type ProviderConnectMethod = Extract type Authorization = IntegrationOauthConnectOutput["data"] +// OpenCode Go and OpenCode Zen both bill through the OpenCode Console, so the +// Console sign-in is the connection method for both providers. +export const CONSOLE_INTEGRATION = "opencode" +export const CONSOLE_PROVIDERS = new Set(["opencode", "opencode-go"]) + +export function consoleIntegration(provider: string) { + return CONSOLE_PROVIDERS.has(provider) ? CONSOLE_INTEGRATION : provider +} + +function hiddenDefaults(method: ProviderConnectMethod | undefined) { + return Object.fromEntries( + (method?.form ?? []).flatMap((field) => + field.type !== "external" && field.hidden && field.default !== undefined ? [[field.key, field.default]] : [], + ), + ) as FormAnswer +} + export function createProviderConnectionController(options: { provider: () => string + /** Integration that stores API keys when it differs from the one that lists methods and runs OAuth. */ + keyProvider?: () => string directory: () => string | undefined onComplete: () => void + /** Picks the method to start without asking when the integration exposes several. */ + autoSelect?: (methods: ProviderConnectMethod[]) => number | undefined pollInterval?: number }) { const language = useLanguage() @@ -23,12 +49,28 @@ export function createProviderConnectionController(options: { const directory = options.directory() return directory ? { directory } : undefined } - const [integration] = createResource( - () => ({ provider: options.provider(), directory: options.directory() }), - (input) => - serverSDK.api.integration - .get({ integrationID: input.provider, location: location() }) - .then((result) => result.data), + // Not createResource: the dialog is owned by whichever page opened it, so reading a pending + // resource here would suspend that page's and blank the screen behind the dialog. + const [integration, setIntegration] = createStore({ + loading: true, + latest: undefined as IntegrationInfo | undefined, + }) + createEffect( + on( + () => ({ provider: options.provider(), directory: options.directory() }), + (input) => { + setIntegration({ loading: true, latest: undefined }) + serverSDK.api.integration + .get({ integrationID: input.provider, location: location() }) + .then((result) => result.data) + .catch(() => undefined) + .then((latest) => { + if (polling.disposed) return + if (input.provider !== options.provider() || input.directory !== options.directory()) return + setIntegration({ loading: false, latest }) + }) + }, + ), ) const methods = createMemo(() => { const values = integration.latest?.methods.filter( @@ -41,17 +83,28 @@ export function createProviderConnectionController(options: { methodIndex: undefined as number | undefined, authorization: undefined as Authorization | undefined, formAnswer: undefined as FormAnswer | undefined, - state: "pending" as "pending" | "complete" | "error" | "form" | undefined, + // Nothing is in flight until a method is selected; `busy()` reads this, so a truthy initial + // value would keep multi-method providers on the spinner instead of the method list. + state: undefined as "pending" | "complete" | "error" | "form" | undefined, error: undefined as string | undefined, + auto: false, }) const polling = { generation: 0, timer: undefined as ReturnType | undefined, disposed: false, + // An attempt the server still considers open; cancelled when the dialog goes away. + attempt: undefined as Authorization | undefined, } const currentMethod = createMemo(() => store.methodIndex === undefined ? undefined : methods().at(store.methodIndex), ) + const autoIndex = createMemo(() => { + if (integration.loading) return undefined + const values = methods() + if (values.length === 1) return 0 + return options.autoSelect?.(values) + }) type Action = | { type: "method.select"; index: number } @@ -109,6 +162,14 @@ export function createProviderConnectionController(options: { ) } + const cancelAttempt = () => { + const attempt = polling.attempt + polling.attempt = undefined + if (!attempt) return + void serverSDK.api.integration.oauth + .cancel({ integrationID: options.provider(), attemptID: attempt.attemptID, location: location() }) + .catch(() => undefined) + } const cancelPolling = () => { polling.generation++ if (polling.timer === undefined) return @@ -117,6 +178,7 @@ export function createProviderConnectionController(options: { } const finish = async () => { cancelPolling() + polling.attempt = undefined const ref = location() data.location.integration.invalidate(ref) data.location.provider.invalidate(ref) @@ -140,6 +202,7 @@ export function createProviderConnectionController(options: { .catch((error) => ({ ok: false as const, error })) if (polling.disposed || generation !== polling.generation) return if (!result.ok) { + polling.attempt = undefined dispatch({ type: "auth.error", error: result.error instanceof Error ? result.error.message : String(result.error), @@ -151,26 +214,35 @@ export function createProviderConnectionController(options: { return } if (result.status.status === "failed") { + polling.attempt = undefined dispatch({ type: "auth.error", error: result.status.message }) return } if (result.status.status === "expired") { - dispatch({ type: "auth.error", error: language.t("common.requestFailed") }) + polling.attempt = undefined + dispatch({ type: "auth.error", error: language.t("provider.connect.oauth.expired") }) return } polling.timer = setTimeout(() => void poll(authorization, generation), options.pollInterval ?? 1_000) } + const open = () => { + const url = store.authorization?.url + if (url) platform.openExternal(url) + } const select = async (index: number, answer?: FormAnswer) => { cancelPolling() + cancelAttempt() const generation = polling.generation const selected = methods()[index] dispatch({ type: "method.select", index }) - if (selected.form?.length && !answer) { + const visible = (selected.form ?? []).some((field) => field.type === "external" || !field.hidden) + if (visible && !answer) { dispatch({ type: "auth.form" }) return } + const merged = { ...hiddenDefaults(selected), ...answer } if (selected.type === "key") { - dispatch({ type: "auth.answer", answer }) + dispatch({ type: "auth.answer", answer: Object.keys(merged).length ? merged : undefined }) return } if (selected.type !== "oauth") return @@ -183,11 +255,11 @@ export function createProviderConnectionController(options: { .connect({ integrationID: options.provider(), methodID: selected.id, - ...(answer ? { answer } : {}), + ...(Object.keys(merged).length ? { answer: merged } : {}), location: location(), }) .then((response) => { - if (options.provider() === "opencode" && platform.platform === "desktop") { + if (options.provider() === CONSOLE_INTEGRATION && platform.platform === "desktop") { const url = new URL(response.data.url) url.searchParams.set("client_id", "opencode-desktop") response.data.url = url.href @@ -195,21 +267,44 @@ export function createProviderConnectionController(options: { return { ok: true as const, authorization: response.data } }) .catch((error) => ({ ok: false as const, error })) - if (polling.disposed || generation !== polling.generation) return - if (!result.ok) { - dispatch({ type: "auth.error", error: String(result.error) }) + if (polling.disposed || generation !== polling.generation) { + if (result.ok) + void serverSDK.api.integration.oauth + .cancel({ + integrationID: options.provider(), + attemptID: result.authorization.attemptID, + location: location(), + }) + .catch(() => undefined) return } + if (!result.ok) { + dispatch({ + type: "auth.error", + error: result.error instanceof Error ? result.error.message : String(result.error), + }) + return + } + polling.attempt = result.authorization dispatch({ type: "auth.complete", authorization: result.authorization }) + // Same as `opencode auth login`: hand the user straight to the browser instead of + // asking them to click a link and retype a code. + platform.openExternal(result.authorization.url) if (result.authorization.mode === "auto") void poll(result.authorization, generation) } + const retry = () => { + const index = store.methodIndex + if (index === undefined) return + void select(index, store.formAnswer) + } const reset = () => { cancelPolling() + cancelAttempt() dispatch({ type: "method.reset" }) } const connectKey = async (key: string) => { await serverSDK.api.integration.connect.key({ - integrationID: options.provider(), + integrationID: options.keyProvider?.() ?? options.provider(), location: location(), key, ...(store.formAnswer ? { answer: store.formAnswer } : {}), @@ -236,15 +331,16 @@ export function createProviderConnectionController(options: { return undefined } - let auto = false createEffect(() => { - if (auto || integration.loading || methods().length !== 1) return - auto = true - void select(0) + const index = autoIndex() + if (store.auto || index === undefined) return + setStore("auto", true) + void select(index) }) onCleanup(() => { polling.disposed = true cancelPolling() + cancelAttempt() }) return { @@ -254,11 +350,19 @@ export function createProviderConnectionController(options: { currentMethod, methodIndex: () => store.methodIndex, authorization: () => store.authorization, + // True while nothing useful can be shown yet: the integration is loading, a method is + // about to be picked automatically, or the authorization request is in flight. + busy: () => + integration.loading || + (store.methodIndex === undefined && !store.auto && autoIndex() !== undefined) || + store.state === "pending", auth: { state: () => store.state, error: () => store.error, select, reset, + retry, + open, connectKey, completeCode, }, diff --git a/packages/app/src/providers/connect/dialog.tsx b/packages/app/src/providers/connect/dialog.tsx index fd063605e09..0e5576998ab 100644 --- a/packages/app/src/providers/connect/dialog.tsx +++ b/packages/app/src/providers/connect/dialog.tsx @@ -8,16 +8,23 @@ import { TextField } from "@opencode/ui/text-field" import { DialogBody, DialogHeader, DialogTitle, Dialog } from "@opencode/ui/dialog" import { TextInput } from "@opencode/ui/text-input" import { showToast } from "@/shell/notifications/toast" -import { type Component, createMemo, createUniqueId, For, Match, onMount, Show, Switch } from "solid-js" +import { type Component, createMemo, createUniqueId, For, type JSX, Match, onMount, Show, Switch } from "solid-js" import { createStore } from "solid-js/store" import { useParams } from "@solidjs/router" import { ExternalLink } from "@/runtime/platform/external-link" import { useLanguage } from "@/runtime/i18n/language" +import { useData } from "@/runtime/server/current" +import { useGlobal } from "@/runtime/server/runtime" import { useProviders } from "@/providers/catalog/providers" import { useIntegrations } from "@/providers/catalog/integrations" import { CustomProviderForm } from "@/providers/credentials/dialog" import { decode64 } from "@/runtime/persistence/base64" -import { createProviderConnectionController, type ProviderConnectMethod } from "./controller" +import { + CONSOLE_PROVIDERS, + consoleIntegration, + createProviderConnectionController, + type ProviderConnectMethod, +} from "./controller" const CUSTOM_ID = "_custom" type IntegrationForm = NonNullable[number] @@ -258,13 +265,42 @@ function ProviderConnection(props: { const dialog = useDialog() const params = useParams() const language = useLanguage() + const data = useData() + const global = useGlobal() const providers = useProviders(() => props.directory) + const integrations = useIntegrations(() => props.directory) const directory = () => props.directory ?? decode64(params.dir) + const location = () => { + const value = directory() + return value ? { directory: value } : undefined + } + const integrationID = () => consoleIntegration(props.provider) + const isConsole = () => CONSOLE_PROVIDERS.has(props.provider) const controller = createProviderConnectionController({ - provider: () => props.provider, + provider: integrationID, + // A Go service-account key still belongs to the `opencode-go` integration (zen/go/v1), + // exactly as before; only the sign-in is shared with the Console. + keyProvider: () => props.provider, directory, + autoSelect: (methods) => { + if (!isConsole()) return undefined + const index = methods.findIndex((method) => method.type === "oauth") + return index === -1 ? undefined : index + }, onComplete: () => { + // The picker only lists the newest model per family by default, which hides most of + // what a new connection just unlocked. Show everything the connected integration offers. + // Console sign-in returns Go models inside the `opencode` provider, so this covers Go too. + const linked = (data.location.provider.list(location()) ?? []).filter( + (item) => item.id === props.provider || item.integrationID === integrationID(), + ) + const ids = new Set(linked.map((item) => item.id)) + global.models.show( + (data.location.model.list(location()) ?? []) + .filter((model) => ids.has(model.providerID) && model.status !== "deprecated") + .map((model) => ({ providerID: model.providerID, modelID: model.id })), + ) dialog.close() showToast({ variant: "success", @@ -276,7 +312,11 @@ function ProviderConnection(props: { }) const provider = createMemo(() => ({ id: props.provider, - name: providers.all().get(props.provider)?.name ?? controller.integration()?.name ?? props.provider, + name: + integrations.list().find((item) => item.id === props.provider)?.name ?? + providers.all().get(props.provider)?.name ?? + controller.integration()?.name ?? + props.provider, })) const methodLabel = (value?: { type?: string; label?: string }) => { if (!value) return "" @@ -298,6 +338,13 @@ function ProviderConnection(props: { : undefined, } } + const code = createMemo(() => { + const instructions = controller.authorization()?.instructions + if (instructions?.includes(":")) return instructions.split(":").pop()?.trim() + return instructions + }) + const keyIndex = () => controller.methods().findIndex((method) => method.type === "key") + const oauthIndex = () => controller.methods().findIndex((method) => method.type === "oauth") function AuthFormView() { const [formStore, setFormStore] = createStore({ @@ -307,7 +354,7 @@ function ProviderConnection(props: { const fields = createMemo(() => { const value = controller.currentMethod() - return (value?.form ?? []).flatMap((field) => (field.type === "string" ? [field] : [])) + return (value?.form ?? []).flatMap((field) => (field.type === "string" && !field.hidden ? [field] : [])) }) const matches = (field: StringForm, value: Record) => { return (field.when ?? []).every((condition) => { @@ -364,7 +411,7 @@ function ProviderConnection(props: { }) return ( -
+ 1 && controller.methodIndex() !== undefined) { + // The API key path for the Console is an escape hatch below the sign-in flow, so + // "back" returns to the sign-in rather than leaving the provider. + if (isConsole() && controller.currentMethod()?.type === "key" && oauthIndex() !== -1) { + void controller.auth.select(oauthIndex()) + return + } + if (!isConsole() && controller.methods().length > 1 && controller.methodIndex() !== undefined) { controller.auth.reset() return } @@ -463,6 +516,31 @@ function ProviderConnection(props: { ) } + function StatusRow(input: { children: JSX.Element }) { + return ( +
+ + {input.children} +
+ ) + } + + function ErrorRow() { + return ( +
+
+ + + {language.t("provider.connect.status.failed", { error: controller.auth.error() ?? "" })} + +
+ +
+ ) + } + function ApiAuthView() { let apiKey: HTMLInputElement | undefined const errorID = createUniqueId() @@ -494,22 +572,17 @@ function ProviderConnection(props: { return (
-
-
{language.t("provider.connect.opencodeZen.line1")}
-
{language.t("provider.connect.opencodeZen.line2")}
-
- {language.t("provider.connect.opencodeZen.visit.prefix")} - - {language.t("provider.connect.opencodeZen.visit.link")} - - {language.t("provider.connect.opencodeZen.visit.suffix")} -
+
+ {language.t("provider.connect.console.apiKey.description")}{" "} + + {language.t("provider.connect.console.apiKey.link")} +
@@ -574,13 +647,10 @@ function ProviderConnection(props: { return (
-
- {language.t("provider.connect.oauth.code.visit.prefix")} - - {language.t("provider.connect.oauth.code.visit.link")} - - {language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })} -
+
{language.t("provider.connect.oauth.code.description", { provider: provider().name })}
+
) diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index 422855ca7cf..3a9f3da648c 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -87,6 +87,7 @@ export const dict = { "command.project.index": "Switch to project {{index}}", "command.project.copyID": "Copy Project ID", "command.provider.connect": "Connect provider", + "command.provider.connect.description": "Sign in to OpenCode Go, OpenCode Console, or another model provider", "command.server.switch": "Switch server", "command.settings.open": "Open settings", "command.session.previous": "Previous session", @@ -227,6 +228,22 @@ export const dict = { "provider.connect.oauth.auto.visit.suffix": " and enter the code below to connect your account and use {{provider}} models in OpenCode.", "provider.connect.oauth.auto.confirmationCode": "Confirmation code", + "provider.connect.oauth.auto.confirmationCode.description": + "Check that your browser shows the same code before you authorize.", + "provider.connect.oauth.auto.description": + "Your browser opens so you can sign in to {{provider}}. Come back here when you are done.", + "provider.connect.oauth.code.description": + "Your browser opens so you can sign in to {{provider}}. Paste the authorization code it gives you below.", + "provider.connect.oauth.openBrowser": "Open browser", + "provider.connect.oauth.expired": "Authorization expired", + "provider.connect.console.description": + "Sign in with your OpenCode Console account. Your browser opens to the Console, where you pick a workspace and select Authorize.", + "provider.connect.console.opening": "Opening your browser…", + "provider.connect.console.code.placeholder": "Requesting a code…", + "provider.connect.console.apiKey.switch": "Connect with an API key or service account instead", + "provider.connect.console.apiKey.description": + "Paste an API key for a service account. You create service accounts in the OpenCode Console under Keys.", + "provider.connect.console.apiKey.link": "Open the Console", "provider.connect.toast.connected.title": "{{provider}} connected", "provider.connect.toast.connected.description": "{{provider}} models are now available to use.", @@ -304,6 +321,7 @@ export const dict = { "common.connect": "Connect", "common.disconnect": "Disconnect", "common.continue": "Continue", + "common.retry": "Try again", "common.submit": "Submit", "common.save": "Save", "common.saving": "Saving…", @@ -1369,6 +1387,7 @@ export const dict = { "settings.providers.tag.environment": "Environment", "settings.providers.tag.config": "Config", "settings.providers.tag.custom": "Custom", + "settings.providers.tag.account": "Account", "settings.providers.tag.other": "Other", "settings.models.title": "Models", "settings.models.description": "Choose which models appear in model picker", diff --git a/packages/app/src/runtime/server/global-sync/utils.ts b/packages/app/src/runtime/server/global-sync/utils.ts index 00dbe61f77b..023e6344953 100644 --- a/packages/app/src/runtime/server/global-sync/utils.ts +++ b/packages/app/src/runtime/server/global-sync/utils.ts @@ -51,6 +51,7 @@ export function normalizeProviderList( for (const provider of providers) { all.set(provider.id, { id: provider.id, + integrationID: provider.integrationID, name: provider.name, source: "custom", env: [], diff --git a/packages/app/src/runtime/server/runtime.tsx b/packages/app/src/runtime/server/runtime.tsx index 4026fe35d9e..5d3fa437248 100644 --- a/packages/app/src/runtime/server/runtime.tsx +++ b/packages/app/src/runtime/server/runtime.tsx @@ -1,5 +1,5 @@ import { createSimpleContext } from "@opencode/ui/context" -import { Accessor, createEffect, createMemo, createResource, createRoot, getOwner } from "solid-js" +import { Accessor, batch, createEffect, createMemo, createResource, createRoot, getOwner } from "solid-js" import { createServerProjects, RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServers } from "./registry" import { pathKey } from "@/workspaces/path-key" import { useServerHealth } from "@/runtime/server/health" @@ -98,6 +98,25 @@ function createGlobalModels() { set: setStore, ready, recent: () => recent()!, + // Marks models visible in the picker regardless of the "latest per family" default. + show(models: ReadonlyArray<{ providerID: string; modelID: string }>) { + const seen = new Map(store.user.map((item, index) => [`${item.providerID}:${item.modelID}`, index])) + batch(() => { + for (const model of models) { + const index = seen.get(`${model.providerID}:${model.modelID}`) + if (index !== undefined) { + setStore("user", index, "visibility", "show") + continue + } + seen.set(`${model.providerID}:${model.modelID}`, store.user.length) + setStore("user", store.user.length, { + providerID: model.providerID, + modelID: model.modelID, + visibility: "show", + }) + } + }) + }, } } diff --git a/packages/app/src/runtime/server/types.ts b/packages/app/src/runtime/server/types.ts index 0e08e3969cf..3f327626d43 100644 --- a/packages/app/src/runtime/server/types.ts +++ b/packages/app/src/runtime/server/types.ts @@ -129,6 +129,8 @@ export type Model = { export type Provider = { id: string + /** Integration that connects this provider; differs from `id` for Console-managed providers. */ + integrationID?: string name: string source: "env" | "config" | "custom" | "api" env: string[] diff --git a/packages/app/src/settings/providers/providers.tsx b/packages/app/src/settings/providers/providers.tsx index 42919a0a3ab..7ec2e8e3c3c 100644 --- a/packages/app/src/settings/providers/providers.tsx +++ b/packages/app/src/settings/providers/providers.tsx @@ -12,7 +12,7 @@ import { DialogConnectProvider, useProviderConnectController } from "@/providers import { SettingsList } from "@/settings/list" import "@/settings/settings.css" -type ProviderSource = "env" | "api" | "config" | "custom" +type ProviderSource = "env" | "api" | "account" | "config" | "custom" type ProviderItem = ReturnType["connected"]>[number] const PROVIDER_NOTES = [ @@ -38,7 +38,12 @@ export const SettingsProviders: Component<{ const providers = useProviders(() => props.directory) const integrations = useIntegrations(() => props.directory) const providerConnect = useProviderConnectController({ onBack: props.onBack }) - const integration = (providerID: string) => integrations.list().find((item) => item.id === providerID) + // Console-managed providers (`opencode-go`, `console-*`) connect through the `opencode` + // integration, so the lookup must follow `integrationID` rather than the provider id. + const integration = (item: ProviderItem) => { + const id = item.integrationID ?? item.id + return integrations.list().find((entry) => entry.id === id) + } const connect = (provider?: string) => { providerConnect.select(provider) @@ -69,8 +74,9 @@ export const SettingsProviders: Component<{ // connections mean an API key or OAuth grant, env connections mean detected // environment variables, and a connectionless integration is config-provided. const source = (item: ProviderItem): ProviderSource | undefined => { - const current = integration(item.id) - if (current?.connections.some((connection) => connection.type === "credential")) return "api" + const current = integration(item) + const credential = current?.connections.find((connection) => connection.type === "credential") + if (credential) return credential.method === "oauth" ? "account" : "api" if (current?.connections.some((connection) => connection.type === "env")) return "env" if (current) return "config" if (!("source" in item)) return @@ -83,13 +89,14 @@ export const SettingsProviders: Component<{ const current = source(item) if (current === "env") return language.t("settings.providers.tag.environment") if (current === "api") return language.t("provider.connect.method.apiKey") + if (current === "account") return language.t("settings.providers.tag.account") if (current === "config") return language.t("settings.providers.tag.config") if (current === "custom") return language.t("settings.providers.tag.custom") return language.t("settings.providers.tag.other") } const canDisconnect = (item: ProviderItem) => { - const current = integration(item.id) + const current = integration(item) if (current) return current.connections.some((connection) => connection.type === "credential") const currentSource = source(item) return currentSource !== "env" && currentSource !== "config" @@ -97,10 +104,11 @@ export const SettingsProviders: Component<{ const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key - const disconnect = async (providerID: string, name: string) => { + const disconnect = async (item: ProviderItem) => { + const name = item.name const location = props.directory ? { directory: props.directory } : undefined await serverSdk.api.integration - .get({ integrationID: providerID, location }) + .get({ integrationID: item.integrationID ?? item.id, location }) .then(async (integration) => { const credentials = integration.data?.connections.filter((item) => item.type === "credential") ?? [] if (credentials.length === 0) throw new Error(`No removable credentials found for ${name}`) @@ -162,7 +170,7 @@ export const SettingsProviders: Component<{ } > - diff --git a/packages/cli/test/auth.test.ts b/packages/cli/test/auth.test.ts index f959036d7d1..1f8a9a4e4f6 100644 --- a/packages/cli/test/auth.test.ts +++ b/packages/cli/test/auth.test.ts @@ -49,7 +49,7 @@ describe("auth command", () => { name: "Anthropic", methods: [], connections: [ - { type: "credential", id: "cred_test", label: "default" }, + { type: "credential", method: "key", id: "cred_test", label: "default" }, { type: "env", name: "ANTHROPIC_API_KEY" }, ], }, @@ -67,7 +67,7 @@ describe("auth command", () => { id: "anthropic", name: "Anthropic", connections: [ - { type: "credential", id: "cred_test", label: "default" }, + { type: "credential", method: "key", id: "cred_test", label: "default" }, { type: "env", name: "ANTHROPIC_API_KEY" }, ], }, @@ -267,7 +267,7 @@ describe("auth command", () => { id: "anthropic", name: "Anthropic", methods: [{ type: "key" }], - connections: [{ type: "credential", id: "cred_test", label: "default" }], + connections: [{ type: "credential", method: "key", id: "cred_test", label: "default" }], }, ]), ) diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 9cacfc6bc36..a335a9c2a43 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -230,7 +230,7 @@ export type IntegrationCommandMethod = { id: string; type: "command"; label: str export type IntegrationEnvMethod = { type: "env"; names: Array } -export type ConnectionCredentialInfo = { type: "credential"; id: string; label: string } +export type ConnectionCredentialInfo = { type: "credential"; id: string; label: string; method: "key" | "oauth" } export type ConnectionEnvInfo = { type: "env"; name: string } diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts index e93549b1143..949a0cb7ff2 100644 --- a/packages/core/src/integration.ts +++ b/packages/core/src/integration.ts @@ -361,6 +361,7 @@ const layer = Layer.effect( type: "credential" as const, id: credential.id, label: credential.label, + method: credential.value.type, })) .toReversed() const env = (entry?.methods ?? []) @@ -731,8 +732,7 @@ const layer = Layer.effect( connect: connectOAuth, status: Effect.fn("Integration.oauth.status")(function* (input) { const attempt = (yield* SynchronizedRef.get(attempts)).get(input.attemptID) - if (!attempt || attempt.integrationID !== input.integrationID) - return yield* new AttemptNotFoundError(input) + if (!attempt || attempt.integrationID !== input.integrationID) return yield* new AttemptNotFoundError(input) if (attempt.status === "failed") { return { status: attempt.status, message: attempt.message ?? "Authorization failed", time: attempt.time } } @@ -777,8 +777,7 @@ const layer = Layer.effect( connect: connectCommand, status: Effect.fn("Integration.command.status")(function* (input) { const attempt = (yield* SynchronizedRef.get(commandAttempts)).get(input.attemptID) - if (!attempt || attempt.integrationID !== input.integrationID) - return yield* new AttemptNotFoundError(input) + if (!attempt || attempt.integrationID !== input.integrationID) return yield* new AttemptNotFoundError(input) if (attempt.status === "pending") { return { status: attempt.status, diff --git a/packages/core/src/plugin/provider/opencode.ts b/packages/core/src/plugin/provider/opencode.ts index 8fd2772acb0..226b3911d77 100644 --- a/packages/core/src/plugin/provider/opencode.ts +++ b/packages/core/src/plugin/provider/opencode.ts @@ -165,7 +165,7 @@ export const OpencodePlugin = define { editor.update("opencode", (integration) => { - integration.name = "OpenCode" + integration.name = "OpenCode Console" }) editor.method.update(oauth(http)) editor.method.update({ integrationID: "opencode", method: { type: "key", label: "API key (service account)" } }) diff --git a/packages/core/test/integration.test.ts b/packages/core/test/integration.test.ts index 5458ada55e1..f2b10246b10 100644 --- a/packages/core/test/integration.test.ts +++ b/packages/core/test/integration.test.ts @@ -599,16 +599,19 @@ describe("Integration", () => { expect((yield* integrations.get(integrationID))?.connections).toEqual([ { type: "credential", + method: "key", id: personal.id, label: "Personal", }, { type: "credential", + method: "key", id: work.id, label: "Work", }, { type: "credential", + method: "key", id: archived.id, label: "Archived", }, @@ -616,6 +619,7 @@ describe("Integration", () => { ]) expect(yield* integrations.connection.active(integrationID)).toEqual({ type: "credential", + method: "key", id: personal.id, label: "Personal", }) @@ -627,6 +631,7 @@ describe("Integration", () => { expect(yield* integrations.connection.active(integrationID)).toEqual({ type: "credential", + method: "key", id: work.id, label: "Work", }) @@ -649,6 +654,7 @@ describe("Integration", () => { yield* integrations.connection.remove(work.id) expect(yield* integrations.connection.active(integrationID)).toEqual({ type: "credential", + method: "key", id: personal.id, label: "Personal", }) diff --git a/packages/core/test/plugin.test.ts b/packages/core/test/plugin.test.ts index bce688b4c07..cb2150dfad0 100644 --- a/packages/core/test/plugin.test.ts +++ b/packages/core/test/plugin.test.ts @@ -568,9 +568,14 @@ it.effect("refreshes expired OAuth credentials through the context during activa expect(yield* plugins.list()).toMatchObject([{ id: "oauth-refresh", state: { status: "active" } }]) expect(resolved).toEqual([refreshed]) expect((yield* credentials.get(stored.id))?.value).toEqual(refreshed) - expect(yield* integrations.connection.resolve({ type: "credential", id: stored.id, label: stored.label })).toEqual( - refreshed, - ) + expect( + yield* integrations.connection.resolve({ + type: "credential", + method: "oauth", + id: stored.id, + label: stored.label, + }), + ).toEqual(refreshed) expect(refreshes).toEqual([expired]) }), ) diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index ba120a3d873..fe9992a7b9e 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -320,7 +320,12 @@ describe("OpencodePlugin", () => { expect(config).toEqual([{ authorization: "Bearer access", orgID: scenario.orgID ?? "org-a" }]) const integrations = yield* Integration.Service expect( - yield* integrations.connection.resolve({ type: "credential", id: initial.id, label: initial.label }), + yield* integrations.connection.resolve({ + type: "credential", + method: "oauth", + id: initial.id, + label: initial.label, + }), ).toEqual(stored.value) expect(requests).toEqual(["/auth/device/token", "/api/v2/config"]) }), diff --git a/packages/core/test/plugin/provider-poe.test.ts b/packages/core/test/plugin/provider-poe.test.ts index f7aaebc97cc..c7afed50f19 100644 --- a/packages/core/test/plugin/provider-poe.test.ts +++ b/packages/core/test/plugin/provider-poe.test.ts @@ -319,7 +319,7 @@ it.effect("keeps near-expiry keys usable and requires a new login after expiry", expires: (yield* Clock.currentTimeMillis) + 120_000, }), }) - const connection = { type: "credential" as const, id: saved.id, label: saved.label } + const connection = { type: "credential" as const, method: "oauth" as const, id: saved.id, label: saved.label } expect(yield* test.integrations.connection.resolve(connection)).toEqual(saved.value) yield* TestClock.adjust("2 minutes") const error = yield* test.integrations.connection.resolve(connection).pipe(Effect.flip) diff --git a/packages/protocol/openapi.json b/packages/protocol/openapi.json index 690e45c88fd..23b920807a3 100644 --- a/packages/protocol/openapi.json +++ b/packages/protocol/openapi.json @@ -13602,9 +13602,13 @@ }, "label": { "type": "string" + }, + "method": { + "type": "string", + "enum": ["key", "oauth"] } }, - "required": ["type", "id", "label"], + "required": ["type", "id", "label", "method"], "additionalProperties": false }, "Connection.EnvInfo": { diff --git a/packages/schema/src/connection.ts b/packages/schema/src/connection.ts index 8cda552c1a7..c8ee922af23 100644 --- a/packages/schema/src/connection.ts +++ b/packages/schema/src/connection.ts @@ -8,6 +8,8 @@ export const CredentialInfo = Schema.Struct({ type: Schema.Literal("credential"), id: Credential.ID, label: Schema.String, + /** How the credential was obtained: a stored key or an OAuth grant. */ + method: Schema.Literals(["key", "oauth"]), }).annotate({ identifier: "Connection.CredentialInfo" }) export interface EnvInfo extends Schema.Schema.Type {} diff --git a/packages/tui/test/cli/cmd/tui/integration-options.test.ts b/packages/tui/test/cli/cmd/tui/integration-options.test.ts index 79f482feb50..28ca51443fb 100644 --- a/packages/tui/test/cli/cmd/tui/integration-options.test.ts +++ b/packages/tui/test/cli/cmd/tui/integration-options.test.ts @@ -67,11 +67,11 @@ describe("credentialConnections", () => { name: "Example", connections: [ { type: "env", name: "EXAMPLE_KEY" }, - { type: "credential", id: "cred_1", label: "Work" }, + { type: "credential", method: "key", id: "cred_1", label: "Work" }, ], }), ), - ).toEqual([{ type: "credential", id: "cred_1", label: "Work" }]) + ).toEqual([{ type: "credential", method: "key", id: "cred_1", label: "Work" }]) }) }) @@ -83,7 +83,7 @@ describe("connectionSummary", () => { id: "example", name: "Example", connections: [ - { type: "credential", id: "cred_1", label: "Work" }, + { type: "credential", method: "key", id: "cred_1", label: "Work" }, { type: "env", name: "EXAMPLE_KEY" }, ], }), diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index 97b0c044558..86233661429 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -1826,7 +1826,7 @@ test("refreshes integrations after integration updates", async () => { id: "openai", name: "OpenAI", methods: [{ type: "key" }], - connections: [{ type: "credential", id: "cred_openai", label: "OpenAI" }], + connections: [{ type: "credential", method: "key", id: "cred_openai", label: "OpenAI" }], }, ], }) @@ -2176,9 +2176,7 @@ test("keeps shell state scoped to location", async () => { }, }, }) - await wait(() => - data.shell.list({ directory: other }).some((shell) => shell.id === "sh_live_other"), - ) + await wait(() => data.shell.list({ directory: other }).some((shell) => shell.id === "sh_live_other")) expect(data.shell.list().map((shell) => shell.id)).toEqual(["sh_default"]) expect( data.shell.listBySession("ses_shared").find((shell) => shell.id === "sh_live_other")?.location.directory, @@ -2638,9 +2636,7 @@ test("resyncs global forms only for the active location after reconnect", async await wait(() => data.session.form.list("global", home)?.[0]?.id === "frm_default_2", 4000) expect(data.session.form.list("global", other)?.[0]?.id).toBe("frm_other_1") expect(requests).toHaveLength(1) - expect(requests.map((url) => url.searchParams.get("location[directory]") ?? directory)).toEqual([ - home.directory, - ]) + expect(requests.map((url) => url.searchParams.get("location[directory]") ?? directory)).toEqual([home.directory]) } finally { app.renderer.destroy() } diff --git a/packages/tui/test/cli/tui/dialog-integration.test.tsx b/packages/tui/test/cli/tui/dialog-integration.test.tsx index 05e8de97a20..744ec506f78 100644 --- a/packages/tui/test/cli/tui/dialog-integration.test.tsx +++ b/packages/tui/test/cli/tui/dialog-integration.test.tsx @@ -154,7 +154,7 @@ test("requires delete confirmation and preserves the account manager when anothe await fixture.app.waitForFrame((frame) => frame.includes("Connected accounts") && !frame.includes("Work")) expect(fixture.requests).toEqual([{ method: "DELETE", path: "/api/credential/cred_work" }]) - expect(fixture.accounts).toEqual([{ type: "credential", id: "cred_personal", label: "Personal" }]) + expect(fixture.accounts).toEqual([{ type: "credential", method: "key", id: "cred_personal", label: "Personal" }]) expect(fixture.reads.model).toBe(0) expect(fixture.reads.provider).toBe(0) expect(fixture.app.captureCharFrame()).toContain("Add account") @@ -198,7 +198,7 @@ test("marks the remaining account active after deleting the active credential", }) expect(fixture.requests).toEqual([{ method: "DELETE", path: "/api/credential/cred_personal" }]) - expect(fixture.accounts).toEqual([{ type: "credential", id: "cred_work", label: "Work" }]) + expect(fixture.accounts).toEqual([{ type: "credential", method: "key", id: "cred_work", label: "Work" }]) } finally { fixture.app.renderer.destroy() } @@ -254,8 +254,8 @@ async function renderIntegration(activeLocation?: LocationRef, form?: FormFields const credentialQueries: string[] = [] const reads = { integration: 0, model: 0, provider: 0 } let accounts = [ - { type: "credential" as const, id: "cred_personal", label: "Personal" }, - { type: "credential" as const, id: "cred_work", label: "Work" }, + { type: "credential" as const, method: "key" as const, id: "cred_personal", label: "Personal" }, + { type: "credential" as const, method: "key" as const, id: "cred_work", label: "Work" }, ] const calls = createFetch(async (url, request) => { diff --git a/packages/tui/test/util/connected-provider.test.ts b/packages/tui/test/util/connected-provider.test.ts index b12d448b36b..0daf55dfa38 100644 --- a/packages/tui/test/util/connected-provider.test.ts +++ b/packages/tui/test/util/connected-provider.test.ts @@ -8,7 +8,9 @@ describe("hasConnectedProvider", () => { }) test("is true after any provider integration is connected", () => { - expect(hasConnectedProvider([{ connections: [{ type: "credential", id: "cred_1", label: "Work" }] }])).toBe(true) + expect( + hasConnectedProvider([{ connections: [{ type: "credential", method: "key", id: "cred_1", label: "Work" }] }]), + ).toBe(true) expect(hasConnectedProvider([{ connections: [{ type: "env", name: "OPENAI_API_KEY" }] }])).toBe(true) }) }) diff --git a/packages/ui/src/components/provider-icons/sprite.svg b/packages/ui/src/components/provider-icons/sprite.svg index c8099d7ad1b..259f696182c 100644 --- a/packages/ui/src/components/provider-icons/sprite.svg +++ b/packages/ui/src/components/provider-icons/sprite.svg @@ -372,10 +372,8 @@ > - + +