feat(app): sign in to OpenCode Go and Console through the browser (#50267)

This commit is contained in:
Luke Parker
2026-09-22 19:34:30 +10:00
committed by GitHub
Unverified
parent 9c18abce47
commit fbacf6a126
25 changed files with 459 additions and 163 deletions
+16
View File
@@ -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, "category">): 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(() => <DialogConnectProvider directory={workspace().directory} />)
}
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"),
+126 -22
View File
@@ -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<IntegrationMethod, { type: "key" | "oauth" }>
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 <Suspense> 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<ProviderConnectMethod[]>(() => {
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<typeof setTimeout> | 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,
},
+198 -96
View File
@@ -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<ProviderConnectMethod["form"]>[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<StringForm[]>(() => {
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<string, string>) => {
return (field.when ?? []).every((condition) => {
@@ -364,7 +411,7 @@ function ProviderConnection(props: {
})
return (
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4 px-3">
<Switch>
<Match when={item()?.field.options === undefined}>
<TextField
@@ -422,7 +469,13 @@ function ProviderConnection(props: {
}
function goBack() {
if (controller.methods().length > 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 (
<div class="flex items-center gap-2 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
<Spinner class="size-4 shrink-0 text-v2-icon-icon-muted" />
<span>{input.children}</span>
</div>
)
}
function ErrorRow() {
return (
<div class="flex flex-col items-start gap-3">
<div class="flex items-start gap-2 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base">
<Icon name="circle-ban-sign" size="small" class="mt-0.5 shrink-0 text-v2-state-fg-danger" />
<span role="alert">
{language.t("provider.connect.status.failed", { error: controller.auth.error() ?? "" })}
</span>
</div>
<Button variant="neutral" onClick={() => controller.auth.retry()}>
{language.t("common.retry")}
</Button>
</div>
)
}
function ApiAuthView() {
let apiKey: HTMLInputElement | undefined
const errorID = createUniqueId()
@@ -494,22 +572,17 @@ function ProviderConnection(props: {
return (
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
<Show
when={provider().id === "opencode"}
when={isConsole()}
fallback={language.t("provider.connect.apiKey.description", { provider: provider().name })}
>
<div class="flex flex-col gap-5">
<div>{language.t("provider.connect.opencodeZen.line1")}</div>
<div>{language.t("provider.connect.opencodeZen.line2")}</div>
<div>
{language.t("provider.connect.opencodeZen.visit.prefix")}
<ExternalLink
href="https://opencode.ai/zen"
class="text-v2-text-text-base focus-visible:rounded-xs focus-visible:outline-2 focus-visible:outline-v2-border-border-focus"
>
{language.t("provider.connect.opencodeZen.visit.link")}
</ExternalLink>
{language.t("provider.connect.opencodeZen.visit.suffix")}
</div>
<div>
{language.t("provider.connect.console.apiKey.description")}{" "}
<ExternalLink
href="https://opencode.ai/console"
class="text-v2-text-text-base focus-visible:rounded-xs focus-visible:outline-2 focus-visible:outline-v2-border-border-focus"
>
{language.t("provider.connect.console.apiKey.link")}
</ExternalLink>
</div>
</Show>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
@@ -574,13 +647,10 @@ function ProviderConnection(props: {
return (
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
<div>
{language.t("provider.connect.oauth.code.visit.prefix")}
<ExternalLink href={controller.authorization()!.url} class="text-v2-text-text-base">
{language.t("provider.connect.oauth.code.visit.link")}
</ExternalLink>
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
</div>
<div>{language.t("provider.connect.oauth.code.description", { provider: provider().name })}</div>
<Button variant="neutral" icon="arrow-up-right" onClick={() => controller.auth.open()}>
{language.t("provider.connect.oauth.openBrowser")}
</Button>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
<label class="flex w-full flex-col gap-1 font-[530] leading-4 text-v2-text-text-base">
{language.t("provider.connect.oauth.code.label", { method: controller.currentMethod()?.label ?? "" })}
@@ -613,34 +683,71 @@ function ProviderConnection(props: {
}
function OAuthAutoView() {
const code = createMemo(() => {
const instructions = controller.authorization()?.instructions
if (instructions?.includes(":")) {
return instructions.split(":").pop()?.trim()
}
return instructions
})
return (
<div class="flex flex-col gap-6">
<div class="text-14-regular text-text-base">
{language.t("provider.connect.oauth.auto.visit.prefix")}
<ExternalLink href={controller.authorization()!.url}>
{language.t("provider.connect.oauth.auto.visit.link")}
</ExternalLink>
{language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
<div>{language.t("provider.connect.oauth.auto.description", { provider: provider().name })}</div>
<StatusRow>{language.t("provider.connect.status.waiting")}</StatusRow>
<div class="flex flex-wrap items-center gap-2">
<Button variant="neutral" icon="arrow-up-right" onClick={() => controller.auth.open()}>
{language.t("provider.connect.oauth.openBrowser")}
</Button>
</div>
<Show when={code()}>
{(value) => (
<TextField
label={language.t("provider.connect.oauth.auto.confirmationCode")}
description={language.t("provider.connect.oauth.auto.confirmationCode.description")}
class="font-mono"
value={value()}
readOnly
copyable
/>
)}
</Show>
</div>
)
}
// Rendered from the first frame of the Console sign-in, before the attempt exists. Only the
// status line changes once the browser opens, so nothing around it moves.
function ConsoleSignInView() {
const ready = () => controller.authorization()?.url !== undefined
return (
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
<div>{language.t("provider.connect.console.description", { provider: provider().name })}</div>
<StatusRow>
{language.t(ready() ? "provider.connect.status.waiting" : "provider.connect.console.opening")}
</StatusRow>
<div class="flex flex-wrap items-center gap-2">
<Button variant="neutral" icon="arrow-up-right" disabled={!ready()} onClick={() => controller.auth.open()}>
{language.t("provider.connect.oauth.openBrowser")}
</Button>
</div>
<TextField
label={language.t("provider.connect.oauth.auto.confirmationCode")}
description={language.t("provider.connect.oauth.auto.confirmationCode.description")}
class="font-mono"
value={code()}
value={code() ?? ""}
placeholder={language.t("provider.connect.console.code.placeholder")}
readOnly
copyable
copyable={code() !== undefined}
/>
<div class="text-14-regular text-text-base flex items-center gap-4">
<Spinner />
<span>{language.t("provider.connect.status.waiting")}</span>
</div>
</div>
)
}
// Deliberately quiet: most people should never need a key, so this stays small and at the bottom.
function ConsoleApiKeySwitch() {
return (
<div class="mt-auto flex justify-end px-3 pt-4 text-[11px] leading-4 text-v2-text-text-muted">
<button
type="button"
data-action="provider-connect-api-key"
class="rounded-xs px-1 underline decoration-v2-border-border-base underline-offset-2 hover:text-v2-text-text-base focus-visible:outline-2 focus-visible:outline-v2-border-border-focus"
onClick={() => void controller.auth.select(keyIndex())}
>
{language.t("provider.connect.console.apiKey.switch")}
</button>
</div>
)
}
@@ -661,53 +768,48 @@ function ProviderConnection(props: {
</div>
</div>
<div class="flex min-h-0 flex-1 flex-col">
<div>
<Switch>
<Match when={controller.loading()}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2">
<Spinner />
<span>{language.t("provider.connect.status.inProgress")}</span>
</div>
</div>
</Match>
<Match when={controller.methodIndex() === undefined}>
<MethodSelection />
</Match>
<Match when={controller.auth.state() === "pending"}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2">
<Spinner />
<span>{language.t("provider.connect.status.inProgress")}</span>
</div>
</div>
</Match>
<Match when={controller.auth.state() === "form"}>
<AuthFormView />
</Match>
<Match when={controller.auth.state() === "error"}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2">
<Icon name="circle-ban-sign" class="text-icon-critical-base" />
<span>{language.t("provider.connect.status.failed", { error: controller.auth.error() ?? "" })}</span>
</div>
</div>
</Match>
<Match when={controller.currentMethod()?.type === "key"}>
<ApiAuthView />
</Match>
<Match when={controller.currentMethod()?.type === "oauth"}>
<Switch>
<Match when={controller.authorization()?.mode === "code"}>
<OAuthCodeView />
</Match>
<Match when={controller.authorization()?.mode === "auto"}>
<OAuthAutoView />
</Match>
</Switch>
</Match>
</Switch>
</div>
<Switch>
<Match
when={
isConsole() &&
controller.currentMethod()?.type !== "key" &&
controller.auth.state() !== "error" &&
(controller.busy() || controller.authorization()?.mode === "auto")
}
>
<ConsoleSignInView />
</Match>
<Match when={controller.busy()}>
<div class="px-3">
<StatusRow>{language.t("provider.connect.status.inProgress")}</StatusRow>
</div>
</Match>
<Match when={controller.methodIndex() === undefined}>
<MethodSelection />
</Match>
<Match when={controller.auth.state() === "form"}>
<AuthFormView />
</Match>
<Match when={controller.auth.state() === "error"}>
<div class="px-3">
<ErrorRow />
</div>
</Match>
<Match when={controller.currentMethod()?.type === "key"}>
<ApiAuthView />
</Match>
<Match when={controller.authorization()?.mode === "code"}>
<OAuthCodeView />
</Match>
<Match when={controller.authorization()?.mode === "auto"}>
<OAuthAutoView />
</Match>
</Switch>
<Show
when={isConsole() && !controller.loading() && controller.currentMethod()?.type !== "key" && keyIndex() !== -1}
>
<ConsoleApiKeySwitch />
</Show>
</div>
</div>
)
+19
View File
@@ -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",
@@ -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: [],
+20 -1
View File
@@ -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",
})
}
})
},
}
}
+2
View File
@@ -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[]
@@ -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<ReturnType<typeof useProviders>["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<{
</span>
}
>
<Button size="normal" variant="ghost-muted" onClick={() => void disconnect(item.id, item.name)}>
<Button size="normal" variant="ghost-muted" onClick={() => void disconnect(item)}>
{language.t("common.disconnect")}
</Button>
</Show>
+3 -3
View File
@@ -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" }],
},
]),
)
@@ -230,7 +230,7 @@ export type IntegrationCommandMethod = { id: string; type: "command"; label: str
export type IntegrationEnvMethod = { type: "env"; names: Array<string> }
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 }
+3 -4
View File
@@ -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,
@@ -165,7 +165,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Manag
yield* ctx.integration.transform((editor) => {
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)" } })
+6
View File
@@ -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",
})
+8 -3
View File
@@ -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])
}),
)
@@ -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"])
}),
@@ -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)
+5 -1
View File
@@ -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": {
+2
View File
@@ -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<typeof EnvInfo> {}
@@ -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" },
],
}),
+3 -7
View File
@@ -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()
}
@@ -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) => {
@@ -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)
})
})
@@ -372,10 +372,8 @@
></path>
</symbol>
<symbol viewBox="0 0 24 24" fill="none" id="opencode">
<path
d="M8.40005 17.4H19.2001V21H4.80005V13.8H8.40005V17.4ZM15.6001 10.2V13.8H8.40005V10.2H15.6001ZM19.2001 10.2H15.6001V6.6H4.80005V3H19.2001V10.2Z"
fill="currentColor"
></path>
<path d="M15.6 17.4H8.4V10.2H15.6V17.4Z" fill="currentColor" fill-opacity="0.4"></path>
<path d="M15.6 6.6H8.4V17.4H15.6V6.6ZM19.2 21H4.8V3H19.2V21Z" fill="currentColor"></path>
</symbol>
<symbol viewBox="0 0 24 24" fill="none" id="opencode-go">
<path

Before

Width:  |  Height:  |  Size: 275 KiB

After

Width:  |  Height:  |  Size: 275 KiB

+5 -1
View File
@@ -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": {
+5 -1
View File
@@ -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": {