diff --git a/packages/app/e2e/regression/settings.spec.ts b/packages/app/e2e/regression/settings.spec.ts index 6ac6987f7a0d..0fe8cf453faf 100644 --- a/packages/app/e2e/regression/settings.spec.ts +++ b/packages/app/e2e/regression/settings.spec.ts @@ -799,8 +799,18 @@ const consoleIntegrations = [ ] for (const row of [ - { provider: "OpenCode Console", connect: "Connect OpenCode Console", signIn: "Connecting to OpenCode Console" }, - { provider: "OpenCode Go", connect: "Connect OpenCode Go", signIn: "Connecting to OpenCode Go" }, + { + provider: "OpenCode Console", + connect: "Connect OpenCode", + signIn: "Connecting to OpenCode", + keys: ["Zen API key", "Go API key"], + }, + { + provider: "OpenCode Go", + connect: "Connect OpenCode Go", + signIn: "Connecting to OpenCode Go", + keys: ["Use API key"], + }, ]) { test(`providers: ${row.provider} signs in from the Console intro`, async ({ page, context }) => { const url = "https://auth.example.test/device?user_code=ABCD-EFGH" @@ -820,9 +830,9 @@ for (const row of [ const intro = page.getByRole("dialog", { name: row.connect, exact: true }) const container = page.locator('[data-slot="dialog-container"]') await expect( - intro.getByText("Sign in with your OpenCode Console account to use the available models."), + intro.getByText("Sign in with your OpenCode account to use your Go subscription or Console workspace."), ).toBeVisible() - await expect(intro.getByRole("button", { name: "Use API key", exact: true })).toBeVisible() + for (const key of row.keys) await expect(intro.getByRole("button", { name: key, exact: true })).toBeVisible() expect((await container.boundingBox())?.height).toBe(512) const popup = page.waitForEvent("popup") @@ -836,11 +846,125 @@ for (const row of [ const signIn = page.getByRole("dialog", { name: row.signIn, exact: true }) await expect(signIn.getByRole("group", { name: "Device code: ABCD-EFGH", exact: true })).toBeVisible() await expect(signIn.getByRole("button", { name: "Copy sign-in link", exact: true })).toBeVisible() - await expect(signIn.getByRole("button", { name: "Use API key", exact: true })).toHaveCount(0) + for (const key of row.keys) await expect(signIn.getByRole("button", { name: key, exact: true })).toHaveCount(0) await expect.poll(async () => (await container.boundingBox())?.height).toBeLessThan(512) }) } +// The OpenCode row stores a Zen key under `opencode` and a Go key under `opencode-go`; sign-in stays with `opencode`. +// Before connecting, only the keyless OpenCode catalog exists, so the connection counts as the first one (any other +// provider with models would count as an existing connection). Connecting adds the Go, integration-less and Console +// workspace providers in the same refresh, so the "Connected to" step must pick only the ones for this connection. +function openCodeCatalog(state: { connected: boolean }) { + const model = (id: string, name: string) => ({ + [id]: { id, name, cost: { input: 0, output: 0 }, limit: { context: 200_000 } }, + }) + + return () => ({ + all: [ + { id: "opencode", integrationID: "opencode", name: "OpenCode", models: model("zen-model", "Zen Model") }, + ...(state.connected + ? [ + { + id: "opencode-go", + integrationID: "opencode-go", + name: "OpenCode Go", + models: model("glm-5-go", "GLM-5 Go"), + }, + { id: "local", name: "Local", models: model("local-model", "Local Model") }, + { + id: "workspace", + integrationID: "opencode", + name: "Workspace", + models: model("workspace-model", "Workspace Model"), + }, + ] + : []), + ], + connected: [], + }) +} + +for (const row of [ + { key: "Zen API key", integrationID: "opencode", models: ["Zen Model"], hidden: ["Local Model", "GLM-5 Go"] }, + { key: "Go API key", integrationID: "opencode-go", models: ["GLM-5 Go"], hidden: ["Local Model"] }, +]) { + test(`providers: the OpenCode row stores a ${row.key} under ${row.integrationID}`, async ({ page }) => { + const state = { connected: false } + const keys: Array<{ integrationID: string; body: unknown }> = [] + const { settings } = await open(page, { + integrations: consoleIntegrations, + provider: openCodeCatalog(state), + onConnectKey: (input) => { + keys.push(input) + state.connected = true + }, + }) + + await settings.getByRole("tab", { name: "Providers", exact: true }).click() + await settings + .locator(".settings-provider-row", { hasText: "OpenCode Console" }) + .getByRole("button", { name: "Connect", exact: true }) + .click() + const dialog = page.getByRole("dialog") + await dialog.getByRole("button", { name: row.key, exact: true }).click() + await dialog.getByPlaceholder("API key").fill("sk-test") + await dialog.getByRole("button", { name: "Continue", exact: true }).click() + + await expect.poll(() => keys).toEqual([{ integrationID: row.integrationID, body: { key: "sk-test" } }]) + const connected = page.getByRole("dialog", { name: /^Connected to / }) + for (const name of row.models) await expect(connected.getByRole("radio", { name, exact: true })).toBeVisible() + for (const name of row.hidden) await expect(connected.getByRole("radio", { name, exact: true })).toHaveCount(0) + }) +} + +test("providers: going back from the Go key to sign in connects only the Console workspace", async ({ + page, + context, +}) => { + const url = "https://auth.example.test/device?user_code=ABCD-EFGH" + const state = { connected: false } + const keys: string[] = [] + const oauth: string[] = [] + await context.route("https://auth.example.test/**", (route) => route.fulfill({ body: "Sign in" })) + const { settings } = await open(page, { + integrations: consoleIntegrations, + provider: openCodeCatalog(state), + onConnectKey: (input) => keys.push(input.integrationID), + onIntegrationOAuth: (input) => { + oauth.push(input.integrationID) + + return { url } + }, + onIntegrationOAuthStatus: () => { + state.connected = true + + return "complete" + }, + }) + + await settings.getByRole("tab", { name: "Providers", exact: true }).click() + await settings + .locator(".settings-provider-row", { hasText: "OpenCode Console" }) + .getByRole("button", { name: "Connect", exact: true }) + .click() + const dialog = page.getByRole("dialog") + await dialog.getByRole("button", { name: "Go API key", exact: true }).click() + await expect(dialog.getByPlaceholder("API key")).toBeVisible() + await dialog.getByRole("button", { name: "Navigate back", exact: true }).click() + + const popup = page.waitForEvent("popup") + await dialog.getByRole("button", { name: "Continue in the browser", exact: true }).click() + await expect(await popup).toHaveURL(url) + + const connected = page.getByRole("dialog", { name: /^Connected to / }) + await expect(connected.getByRole("radio", { name: "Workspace Model", exact: true })).toBeVisible() + await expect(connected.getByRole("radio", { name: "GLM-5 Go", exact: true })).toHaveCount(0) + await expect(connected.getByRole("radio", { name: "Local Model", exact: true })).toHaveCount(0) + expect(oauth).toEqual(["opencode"]) + expect(keys).toEqual([]) +}) + test("providers: the connect dialog lists connected providers and manages their accounts", async ({ page }) => { const accounts = [ { type: "credential", id: "cred_work", label: "Work", method: "key" }, diff --git a/packages/app/src/providers/connect/dialog.tsx b/packages/app/src/providers/connect/dialog.tsx index 15fe5379bf3b..91b3c0485f6c 100644 --- a/packages/app/src/providers/connect/dialog.tsx +++ b/packages/app/src/providers/connect/dialog.tsx @@ -52,7 +52,7 @@ import { import { ConsoleAuthorization } from "./console" import { DialogChatGPTPlanWelcome } from "./chatgpt-welcome" import { authServerName, RemoteAuthNotice } from "./remote" -import { providerAccounts, type ProviderAccount } from "@/settings/providers/accounts" +import { connectPickerIntegrations, providerAccounts, type ProviderAccount } from "@/settings/providers/accounts" import { AccountNameInput } from "@/settings/providers/account-name" import { useProviderAccountActions } from "@/settings/providers/account-actions" import { IconButton } from "@opencode/ui/icon-button" @@ -237,18 +237,27 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string filter: "", }) - const featured = ["opencode-go", "opencode", "anthropic", "openai", "google", "openrouter", "vercel"] + const featured = ["opencode", "opencode-go", "anthropic", "openai", "google", "openrouter", "vercel"] const custom = () => ({ id: CUSTOM_ID, name: language.t("dialog.provider.custom.label") }) const all = createMemo(() => { language.locale() const query = store.filter.trim().toLowerCase() - const values = [custom(), ...integrations.list()] + const values = [ + custom(), + ...connectPickerIntegrations(integrations.list()).map((provider) => + provider.id === "opencode" ? { ...provider, name: language.t("dialog.provider.opencode.name") } : provider, + ), + ] if (!query) return values - return values.filter((provider) => `${provider.id} ${provider.name}`.toLowerCase().includes(query)) + return values.filter((provider) => + `${provider.id} ${provider.name} ${provider.id === "opencode" ? "go zen console" : ""}` + .toLowerCase() + .includes(query), + ) }) const popular = createMemo(() => @@ -363,11 +372,7 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string - {language.t( - provider.id === "opencode" - ? "dialog.provider.opencode.tagline" - : "dialog.provider.opencodeGo.tagline", - )} + {language.t("dialog.provider.opencodeAll.tagline")} {language.t("dialog.provider.tag.recommended")} @@ -450,6 +455,8 @@ function ProviderConnection(props: { switching?: string activating?: string renaming?: string + /** The OpenCode row stores a Go API key under `opencode-go`. */ + keyProvider?: string }>({ adding: false, direct: false, @@ -473,11 +480,14 @@ function ProviderConnection(props: { const showAccounts = () => !!props.accounts && !state.adding && accounts().length > 0 + // Only a key attempt uses the picked key provider, so going back to sign in never reaches Go's providers. + const keyProvider = () => (controller.currentMethod()?.type === "key" ? state.keyProvider : undefined) + const controller = createProviderConnectionController({ 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, + keyProvider: () => keyProvider() ?? props.provider, directory, manual: () => isConsole || (!!props.accounts && accounts().length > 0), prepare: isConsole ? prepareConsoleCatalog : undefined, @@ -534,7 +544,10 @@ function ProviderConnection(props: { const connectionProviders = createMemo(() => (data.location.provider.list(location()) ?? []).filter( - (provider) => provider.id === props.provider || provider.integrationID === integrationID, + (provider) => + provider.id === props.provider || + provider.integrationID === integrationID || + (keyProvider() !== undefined && provider.integrationID === keyProvider()), ), ) @@ -619,10 +632,12 @@ function ProviderConnection(props: { const provider = createMemo(() => ({ id: props.provider, name: - integrations.list().find((item) => item.id === props.provider)?.name ?? - providers.all().get(props.provider)?.name ?? - controller.integration()?.name ?? - props.provider, + props.provider === "opencode" + ? language.t("dialog.provider.opencode.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 }) => { @@ -1040,7 +1055,9 @@ function ProviderConnection(props: {