Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 129 additions & 5 deletions packages/app/e2e/regression/settings.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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")
Expand All @@ -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" },
Expand Down
81 changes: 54 additions & 27 deletions packages/app/src/providers/connect/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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(() =>
Expand Down Expand Up @@ -363,11 +372,7 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string
</Show>
<Show when={CONSOLE_PROVIDERS.has(provider.id) && accountCount(provider) === 0}>
<span class="min-w-0 truncate font-[440] text-v2-text-text-muted">
{language.t(
provider.id === "opencode"
? "dialog.provider.opencode.tagline"
: "dialog.provider.opencodeGo.tagline",
)}
{language.t("dialog.provider.opencodeAll.tagline")}
</span>
<span class="flex h-4 shrink-0 items-center rounded-xs border-[0.5px] border-v2-border-border-base bg-v2-background-bg-layer-03 px-1 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-muted">
{language.t("dialog.provider.tag.recommended")}
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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()),
),
)

Expand Down Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -1040,7 +1055,9 @@ function ProviderConnection(props: {
</p>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
<label class="flex w-full flex-col gap-2 font-[530] leading-4 text-v2-text-text-base">
{language.t("provider.connect.apiKey.label", { provider: provider().name })}
{language.t("provider.connect.apiKey.label", {
provider: props.provider !== "opencode" ? provider().name : keyProvider() ? "Go" : "Zen",
})}
<TextInput
ref={apiKey}
class="!w-full"
Expand Down Expand Up @@ -1320,14 +1337,27 @@ function ProviderConnection(props: {
<Show when={keyIndex() !== -1}>
<div data-component="console-service-account" class="flex h-7 items-center gap-1 text-[13px]">
<span class="text-v2-text-text-faint">{language.t("provider.connect.console.serviceAccount")}</span>
<Button
variant="ghost-muted"
data-action="provider-connect-api-key"
disabled={consoleStarting()}
onClick={() => void controller.auth.select(keyIndex())}
>
{language.t("provider.connect.console.useApiKey")}
</Button>
<For each={props.provider === "opencode" ? [undefined, "opencode-go"] : [undefined]}>
{(keyProvider) => (
<Button
variant="ghost-muted"
data-action="provider-connect-api-key"
disabled={consoleStarting()}
onClick={() => {
setState("keyProvider", keyProvider)
void controller.auth.select(keyIndex())
}}
>
{language.t(
props.provider !== "opencode"
? "provider.connect.console.useApiKey"
: keyProvider
? "provider.connect.opencode.goKey"
: "provider.connect.opencode.zenKey",
)}
</Button>
)}
</For>
</div>
</Show>
</div>
Expand Down Expand Up @@ -1545,9 +1575,6 @@ function ProviderConnection(props: {
>
{language.t("provider.connect.title.anthropicProMax")}
</Match>
<Match when={props.provider === "opencode"}>
{language.t("provider.connect.title", { provider: language.t("provider.connect.opencode.name") })}
</Match>
<Match when={true}>{language.t("provider.connect.title", { provider: provider().name })}</Match>
</Switch>
</DialogTitle>
Expand Down
13 changes: 9 additions & 4 deletions packages/app/src/runtime/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ export const dict = {
"dialog.provider.opencode.note": "Curated models including Claude, GPT, Gemini and more",
"dialog.provider.opencode.tagline": "Reliable optimized models",
"dialog.provider.opencodeGo.tagline": "Low cost subscription for everyone",
"dialog.provider.opencode.name": "OpenCode",
"dialog.provider.opencodeAll.tagline": "Go subscription or Zen API key",
"dialog.provider.anthropic.note": "Direct access to Claude models via API key",
"dialog.provider.copilot.note": "AI models for coding assistance via GitHub Copilot",
"dialog.provider.openai.note": "GPT models for fast, capable general AI tasks",
Expand Down Expand Up @@ -134,12 +136,15 @@ export const dict = {
"provider.connect.opencode.name": "OpenCode Console",
"provider.connect.opencode.freeName": "OpenCode Free",
"provider.connect.opencode.errorTitle": "Connect to OpenCode",
"provider.connect.console.title": "Connecting to OpenCode Console",
"provider.connect.console.title": "Connecting to OpenCode",
"provider.connect.console.titleGo": "Connecting to OpenCode Go",
"provider.connect.console.intro": "Sign in with your OpenCode Console account to use the available models.",
"provider.connect.console.intro":
"Sign in with your OpenCode account to use your Go subscription or Console workspace.",
"provider.connect.opencode.zenKey": "Zen API key",
"provider.connect.opencode.goKey": "Go API key",
"provider.connect.console.continue": "Continue in the browser",
"provider.connect.console.retry": "Try again",
"provider.connect.console.serviceKeyDescription": "Connect using a service-account API key from OpenCode Console.",
"provider.connect.console.serviceKeyDescription": "Paste an API key from OpenCode Console.",
"provider.connect.console.instructions":
"Continue in your browser. Confirm the code shown there matches the one below.",
"provider.connect.console.deviceCode": "Device code",
Expand Down Expand Up @@ -176,7 +181,7 @@ export const dict = {
"provider.connect.console.modelsLoading": "Your models are still loading. Refresh to check again.",
"provider.connect.console.refresh": "Refresh models",
"provider.connect.console.openingBrowser": "Opening browser…",
"provider.connect.console.serviceAccount": "Service account?",
"provider.connect.console.serviceAccount": "Have an API key?",
"provider.connect.console.useApiKey": "Use API key",
"provider.connect.remote.title": "Connecting on “{{server}}”",
"provider.connect.remote.description":
Expand Down
23 changes: 22 additions & 1 deletion packages/app/src/settings/providers/accounts.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { expect, test } from "bun:test"
import type { IntegrationInfo } from "@opencode/client/promise"
import { activeProviderAccount, providerAccounts } from "./accounts"
import { activeProviderAccount, connectPickerIntegrations, providerAccounts } from "./accounts"

const integration = (connections: IntegrationInfo["connections"]): IntegrationInfo => ({
id: "openai",
Expand All @@ -27,3 +27,24 @@ test("provider accounts keep the server's active-first credential order and igno
expect(activeProviderAccount(env)).toBeUndefined()
expect(providerAccounts(undefined)).toEqual([])
})

test("the connect picker folds OpenCode Go into the OpenCode row unless a Go API key is saved", () => {
const go = (connections: IntegrationInfo["connections"]): IntegrationInfo => ({
id: "opencode-go",
name: "OpenCode Go",
methods: [{ type: "key" }],
connections,
})
const opencode: IntegrationInfo = { id: "opencode", name: "OpenCode Console", methods: [], connections: [] }

expect(connectPickerIntegrations([go([]), opencode]).map((item) => item.id)).toEqual(["opencode"])
expect(
connectPickerIntegrations([go([{ type: "env", name: "OPENCODE_API_KEY" }]), opencode]).map((item) => item.id),
).toEqual(["opencode"])
expect(
connectPickerIntegrations([
go([{ type: "credential", id: "cred_go", label: "Go key", method: "key" }]),
opencode,
]).map((item) => item.id),
).toEqual(["opencode-go", "opencode"])
})
Loading
Loading