Skip to content
Open
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
60 changes: 60 additions & 0 deletions packages/app/e2e/regression/settings.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1179,6 +1179,66 @@ test("providers: connecting OpenCode Go keeps the Console row in place", async (
await expect(connected).not.toHaveAttribute("data-flashed")
})

test("providers: signing in to OpenCode Go without a subscription says why there are no Go models", async ({
page,
context,
}) => {
await context.route("https://auth.example.test/**", (route) => route.fulfill({ body: "Sign in" }))
await context.route("https://opencode.ai/go", (route) => route.fulfill({ body: "OpenCode Go" }))

// The Console only returns `opencode-go` for accounts with a Go subscription; this one has none.
const state = { authorized: false }
const free = { "big-pickle": { id: "big-pickle", name: "Big Pickle", cost: { input: 0, output: 0 } } }

const { settings } = await open(page, {
provider: () =>
state.authorized
? {
// The catalog still lists OpenCode Go, unconnected and without models.
all: [
{ id: "opencode", integrationID: "opencode", name: "New User / OpenCode", models: free },
{ id: "opencode-go", name: "OpenCode Go", models: {} },
],
connected: ["opencode"],
default: {},
}
: NO_PROVIDER,
integrations: () => [
{
id: "opencode",
name: "OpenCode Console",
methods: [{ id: "device", type: "oauth", label: "OpenCode Console account" }],
connections: state.authorized
? [{ type: "credential", id: "cred_console", label: "New User", method: "oauth" }]
: [],
},
{ id: "opencode-go", name: "OpenCode Go", methods: [{ type: "key" }], connections: [] },
],
onIntegrationOAuth: () => ({ url: "https://auth.example.test/device?user_code=GOGO-1234" }),
onIntegrationOAuthStatus: () => {
state.authorized = true

return "complete"
},
})

await settings.getByRole("tab", { name: "Providers", exact: true }).click()
await settings
.locator(".settings-provider-row", { hasText: "OpenCode Go" })
.getByRole("button", { name: "Connect", exact: true })
.click()
const signIn = page.waitForEvent("popup")
await page.getByRole("button", { name: "Continue in the browser", exact: true }).click()
await signIn

const connected = page.getByRole("dialog", { name: "Connected to OpenCode Go" })
await expect(connected.getByRole("radio", { name: "Big Pickle", exact: true })).toBeVisible()
await expect(connected.getByText("No Go subscription on this account", { exact: true })).toBeVisible()
const subscribe = page.waitForEvent("popup")
await connected.getByRole("button", { name: "Subscribe", exact: true }).click()
await expect(await subscribe).toHaveURL("https://opencode.ai/go")
})

test("providers: switching Console accounts keeps the list until the new workspace loads", async ({ page }) => {
const accounts = [
{ type: "credential", id: "cred_clara", label: "Clara", method: "oauth" },
Expand Down
28 changes: 27 additions & 1 deletion packages/app/src/providers/connect/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ import "./models.css"

const CUSTOM_ID = "_custom"

const GO_URL = "https://opencode.ai/go"

type IntegrationForm = NonNullable<ProviderConnectMethod["form"]>[number]

type StringForm = Extract<IntegrationForm, { type: "string" }>
Expand Down Expand Up @@ -514,11 +516,17 @@ function ProviderConnection(props: {
}

dialog.close()
const noGo = props.provider === "opencode-go" && noGoSubscription()
showToast({
variant: "success",
icon: "circle-check",
title: language.t("provider.connect.toast.connected.title", { provider: provider().name }),
description: language.t("provider.connect.toast.connected.description", { provider: provider().name }),
description: noGo
? language.t("provider.connect.models.noGo")
: language.t("provider.connect.toast.connected.description", { provider: provider().name }),
actions: noGo
? [{ label: language.t("provider.connect.models.subscribe"), onClick: subscribeToGo }]
: undefined,
})
},
})
Expand Down Expand Up @@ -556,6 +564,13 @@ function ProviderConnection(props: {

const managedProviders = createMemo(() => (isConsole ? consoleProviderGroup(connectionProviders()) : undefined))

// The Console only grants `opencode-go` to accounts with a Go subscription, so a signed-in account without one gets no
// Go models and nothing else would say why.
const noGoSubscription = () =>
isConsole && !(data.location.model.list(location()) ?? []).some((model) => model.providerID === "opencode-go")

const subscribeToGo = () => platform.openExternal(GO_URL)

// The server loads the Console workspace's providers after the grant lands, so the first refresh
// can still show only the free catalog. Poll briefly for the workspace providers before moving on.
async function prepareConsoleCatalog(active: () => boolean) {
Expand Down Expand Up @@ -1435,6 +1450,17 @@ function ProviderConnection(props: {
<p class="shrink-0 pb-5 text-[13px] leading-5 text-v2-text-text-muted">
{language.t("provider.connect.models.description")}
</p>
<Show when={noGoSubscription()}>
<p
data-component="no-go-subscription"
class="-mt-3 flex shrink-0 flex-wrap items-center gap-x-2 pb-4 text-[13px] leading-5 text-v2-text-text-muted"
>
<span>{language.t("provider.connect.models.noGo")}</span>
<Button variant="ghost-muted" onClick={subscribeToGo}>
{language.t("provider.connect.models.subscribe")}
</Button>
</p>
</Show>
<div
data-component="first-provider-model-scroll"
class="settings-panel settings-models min-h-0 flex-1 overflow-y-auto pb-4"
Expand Down
2 changes: 2 additions & 0 deletions packages/app/src/runtime/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,8 @@ export const dict = {
"provider.connect.models.title": "Connected to {{provider}}",
"provider.connect.models.description": "Choose a model to start with. You can switch models anytime.",
"provider.connect.models.available": "Available models",
"provider.connect.models.noGo": "No Go subscription on this account",
"provider.connect.models.subscribe": "Subscribe",
"provider.connect.chatgptWelcome.title": "ChatGPT connected",
"provider.connect.chatgptWelcome.description": "Eligible requests in OpenCode can use your ChatGPT plan.",
"provider.connect.chatgptWelcome.usage": "Manage usage in ChatGPT settings",
Expand Down
32 changes: 31 additions & 1 deletion packages/tui/src/component/dialog-model.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { createMemo, createSignal, Show } from "solid-js"
import { useLocal } from "../context/local"
import { DialogSelect } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { DialogIntegration } from "./dialog-integration"
import * as Open from "@opencode/util/open"
import { credentialConnections, DialogIntegration } from "./dialog-integration"
import { DialogVariant } from "./dialog-variant"
import * as fuzzysort from "fuzzysort"
import { useConnected } from "./use-connected"
Expand All @@ -14,6 +15,10 @@ import { useNewDesigns } from "../config"
import { DialogFreeModels } from "./dialog-model-free"
import { FavoriteMarker, ModelDetail, isFreeModel, prioritizeFavorites, sortModelOptions } from "./model-options"

const GO_URL = "https://opencode.ai/go"
// Stands in for the Go models a Console account without a Go subscription doesn't get.
const NO_GO_SUBSCRIPTION = { providerID: "opencode-go", modelID: "" }

export function DialogModel(props: { providerID?: string }) {
const local = useLocal()
const dialog = useDialog()
Expand Down Expand Up @@ -84,6 +89,15 @@ function DialogConnectedModels(props: {

const showExtra = createMemo(() => connected() && !props.providerID)

const noGoSubscription = createMemo(() => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This decides "no Go subscription" only because opencode-go is missing from the provider list. Desktop polls in prepareConsoleCatalog because the first refresh after the grant can still show only the free catalog. The TUI's connected() in dialog-integration.tsx syncs once and then opens this dialog, so a Go subscriber can briefly (or, if the Console config fetch fails, permanently) be told they have no subscription. Could the row wait until the Console workspace providers have loaded (e.g. any provider with integrationID === "opencode" other than opencode itself, like Desktop's loaded()), or get a real entitlement signal from the server?

const scoped = props.providerID ? providers().get(props.providerID) : undefined
if (props.providerID && scoped?.integrationID !== "opencode" && props.providerID !== "opencode") return false
if (models().some((model) => model.providerID === "opencode-go")) return false
const consoleIntegration = data.location.integration.list(location.ref)?.find((item) => item.id === "opencode")
const active = consoleIntegration ? credentialConnections(consoleIntegration)[0] : undefined
return Boolean(active && !active.status)
})

const options = createMemo(() => {
const needle = query().trim()
const showSections = showExtra() && needle.length === 0
Expand Down Expand Up @@ -178,10 +192,25 @@ function DialogConnectedModels(props: {
return results.map((option) => ({ ...option, description: option.providerName, category: undefined }))
}

const noGoOptions = noGoSubscription()
? [
{
value: NO_GO_SUBSCRIPTION,
title: "No Go subscription on this account",
category: connected() ? "OpenCode Go" : undefined,
footer: newDesigns() ? "subscribe" : "Subscribe",
onSelect() {
Open.openUrl(GO_URL).catch(() => {})
},
},
]
: []

return [
...favoriteOptions,
...recentOptions,
...modelOptions.filter((option) => option.category !== "Free"),
...noGoOptions,
...modelOptions.filter((option) => option.category === "Free"),
]
})
Expand Down Expand Up @@ -217,6 +246,7 @@ function DialogConnectedModels(props: {
title: newDesigns() ? (option) => (isFavorite(option?.value) ? "unfavorite" : "favorite") : "Favorite",
hidden: !connected(),
onTrigger: (option) => {
if (option.value === NO_GO_SUBSCRIPTION) return
local.model.toggleFavorite(option.value as { providerID: string; modelID: string })
},
},
Expand Down
3 changes: 3 additions & 0 deletions packages/tui/test/app-lifecycle.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2325,6 +2325,9 @@ test("free usage limit Subscribe handles mid-auth/mid-sync Escape, sync failure,
const oauthCallsBeforeCachedExpired = oauthConnectCalls
setup.mockInput.pressEnter()
await setup.waitForFrame((frame) => frame.includes("Select model") && frame.includes("Big Pickle"))
// Signed in without a Go subscription: the picker says why there are no Go models.
await setup.waitForFrame((frame) => frame.includes("No Go subscription on this account"))
expect(setup.captureCharFrame()).toMatch(/subscribe/i)
expect(oauthConnectCalls).toBe(oauthCallsBeforeCachedExpired + 1)
setup.mockInput.pressEscape()
await setup.waitForFrame((frame) => !frame.includes("Select model"))
Expand Down
Loading