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
166 changes: 166 additions & 0 deletions packages/app/e2e/regression/composer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,172 @@ test("the free usage limit opens the Go dialog once, leaves a readable notice, a
await expect(view.editor).toHaveText("")
})

test("the Go usage limit opens the composer tray, switches to a free model, and says when free models run out too", async ({
page,
}) => {
await page.clock.setFixedTime(T0)
const sessionID = "ses_go_limit"
const model = { id: "glm-5-go", providerID: "opencode-go" }

const view = await openSession(page, {
name: "GoLimit",
sessions: [{ id: sessionID, agent: "build", model }],
onFileWrite: () => undefined,
provider: () => ({
all: [
{
id: "opencode",
name: "OpenCode",
models: {
"big-pickle": { id: "big-pickle", name: "Big Pickle", cost: { input: 0, output: 0 }, limit: { context: 200_000 } },
},
},
{
id: "opencode-go",
name: "OpenCode Go",
models: {
"glm-5-go": { id: "glm-5-go", name: "GLM-5 Go", cost: { input: 1, output: 1 }, limit: { context: 200_000 } },
},
},
],
connected: ["opencode", "opencode-go"],
default: { build: "opencode-go/glm-5-go" },
}),
pageMessages: () => ({
items: [
{ id: "msg_go_user", type: "user", text: "Refactor the parser", time: { created: T0 } },
{ id: "msg_go_assistant", type: "assistant", agent: "build", model, content: [], time: { created: T0 + 1 } },
],
}),
})

await expect(page.getByText("Refactor the parser", { exact: true })).toBeVisible()

const durable = (seq: number) => ({ aggregateID: sessionID, seq, version: 1 as const })
const location = { directory: view.directory }

const body = {
type: "error",
error: { type: "GoUsageLimitError", message: "Go usage limit exceeded" },
metadata: { workspace: "wrk_go_limit", limitName: "5 hour" },
}

await view.push([
{
id: "evt_go_limit_1",
created: T0 + 2,
type: "session.step.failed",
location,
durable: durable(1),
data: {
sessionID,
assistantMessageID: "msg_go_assistant",
error: {
type: "provider.go-limit",
message: "OpenCode Go usage limit reached.",
status: 429,
response: { body: JSON.stringify(body) },
retryAfterMs: 3_600_000,
},
},
},
])

const tray = page.locator('[data-component="session-go-limit-tray"]')
await expect(tray).toContainText("Go limit reached.")
await expect(tray).toContainText(/Back at .+, in 1h 0m\./)
await expect(page.getByRole("dialog")).toHaveCount(0)
await expect(
page.getByRole("status").filter({ hasText: /^You've reached your OpenCode Go usage limit\. It resets at / }),
).toBeVisible()
await expect(view.editor).toHaveText("Refactor the parser")
await expect(page.getByText("OpenCode Go usage limit reached.")).toHaveCount(0)

// One click moves the session to a free model and keeps the prompt.
await tray.getByRole("button", { name: "Switch to a free model", exact: true }).click()
await expect(tray).toHaveCount(0)
await expect(page.locator('[data-action="composer-model"]')).toContainText("Big Pickle")
await expect(view.editor).toHaveText("Refactor the parser")

// Running out of free messages while the Go limit holds reopens the tray instead of the free-tier upsell.
await view.push([
{
id: "evt_go_limit_2",
created: T0 + 3,
type: "session.step.started",
location,
durable: durable(2),
data: { sessionID, assistantMessageID: "msg_go_assistant_2", agent: "build", model, started: T0 + 3 },
},
{
id: "evt_go_limit_3",
created: T0 + 4,
type: "session.step.failed",
location,
durable: durable(3),
data: {
sessionID,
assistantMessageID: "msg_go_assistant_2",
error: { type: "provider.free-limit", message: "Free usage limit reached.", status: 429, retryAfterMs: 1_800_000 },
},
},
])

await expect(tray).toContainText("Go and free limits reached.")
await expect(tray).toContainText(/Go is back at .+, free models at .+\./)
await expect(tray.getByRole("button", { name: "Use credits", exact: true })).toBeVisible()
await expect(tray.getByRole("button", { name: "Switch to a free model", exact: true })).toHaveCount(0)
await expect(page.getByRole("dialog", { name: "Free limit reached" })).toHaveCount(0)
})

test("ChatGPT's usage limit opens the ChatGPT usage dialog", async ({ page }) => {
await page.clock.setFixedTime(T0)
const sessionID = "ses_chatgpt_limit"
const model = { id: "gpt-5.5", providerID: "openai" }

const view = await openSession(page, {
name: "ChatGPTLimit",
sessions: [{ id: sessionID, agent: "build", model }],
onFileWrite: () => undefined,
pageMessages: () => ({
items: [
{ id: "msg_chatgpt_user", type: "user", text: "Refactor the parser", time: { created: T0 } },
{
id: "msg_chatgpt_assistant",
type: "assistant",
agent: "build",
model,
content: [],
time: { created: T0 + 1 },
},
],
}),
})

await expect(page.getByText("Refactor the parser", { exact: true })).toBeVisible()

await view.push([
{
id: "evt_chatgpt_limit_1",
created: T0 + 2,
type: "session.step.failed",
location: { directory: view.directory },
durable: { aggregateID: sessionID, seq: 1, version: 1 },
data: {
sessionID,
assistantMessageID: "msg_chatgpt_assistant",
error: {
type: "provider.chatgpt-limit",
message: "ChatGPT usage limit reached. Check ChatGPT Settings → Usage for details.",
status: 429,
},
},
},
])

await expect(page.getByRole("dialog", { name: "ChatGPT usage limit reached" })).toBeVisible()
})

test("a catalog refresh cannot replace an existing session's model", async ({ page }) => {
const alpha = { id: "alpha", name: "Alpha", variants: { high: {} } }
const beta = { id: "beta", name: "Beta" }
Expand Down
9 changes: 9 additions & 0 deletions packages/app/src/runtime/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,15 @@ export const dict = {
"session.background.moveRunning": "Move to background",
"session.background.moveInline": "Press {{keybind}} to move running work to the background",
"command.session.background": "Move to background",
"session.goLimit.title": "Go limit reached.",
"session.goLimit.detail": "Back at {{time}}, in {{left}}.",
"session.goLimit.inHours": "{{hours}}h {{minutes}}m",
"session.goLimit.inMinutes": "{{minutes}}m",
"session.goLimit.both.title": "Go and free limits reached.",
"session.goLimit.both.detail": "Go is back at {{go}}, free models at {{free}}.",
"session.goLimit.both.detailGo": "Go is back at {{go}}.",
"session.goLimit.useCredits": "Use credits",
"session.goLimit.switchFree": "Switch to a free model",
"session.queue.count.one": "{{count}} queued",
"session.queue.count.other": "{{count}} queued",
"session.queue.steer": "Steer",
Expand Down
197 changes: 197 additions & 0 deletions packages/app/src/session/composer/go-limit-tray.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import { createMemo, createSignal, onCleanup, Show } from "solid-js"
import type { SessionStepFailed } from "@opencode/client/promise"
import { Button } from "@opencode/ui/button"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { useWorkspaceLocation } from "@/workspaces/location"
import { isFreeModel } from "@/providers/models/models"
import type { ModelSelection } from "@/providers/models/selection"
import { formatLimitReset, goLimitActive, goLimited, goLimitLink } from "../usage-exceeded-dialogs"
import { Grow } from "./spring-height"

// Go's usage window: limits refill over five hours.
const GO_WINDOW_MS = 5 * 3_600_000

type GoLimit = {
/** When Go is back, if the server said. */
goAt?: number
/** Free models ran out too, after switching away from Go. */
both: boolean
freeAt?: number
link: string
}

const resetAt = (event: SessionStepFailed) => {
const ms = event.data.error.retryAfterMs

return ms !== undefined && Number.isFinite(ms) && ms >= 0 ? event.created + ms : undefined
}

// Pullout above the composer for a Go subscriber who hit their usage limit: when Go is back, and one click to keep
// going. It grows out from under the composer like the queue panel.
export function SessionGoLimitTray(props: { sessionID: string | undefined; selection: () => ModelSelection | undefined }) {
const language = useLanguage()
const platform = usePlatform()
const location = useWorkspaceLocation()
const [limit, setLimit] = createSignal<GoLimit>()
const [open, setOpen] = createSignal(false)
const [now, setNow] = createSignal(Date.now())

const timer = setInterval(() => setNow(Date.now()), 15_000)
onCleanup(() => clearInterval(timer))

onCleanup(
location().event.on("session.step.failed", (event) => {
if (!props.sessionID || event.data.sessionID !== props.sessionID) return
const type = event.data.error.type

if (type === "provider.go-limit") {
const goAt = resetAt(event)
goLimited.set(props.sessionID, goAt)
setLimit({ goAt, both: false, link: goLimitLink(event.data.error) })
setNow(Date.now())

return setOpen(true)
}

if (type === "provider.free-limit" && goLimitActive(props.sessionID)) {
setLimit((current) => ({
goAt: current?.goAt ?? goLimited.get(props.sessionID!),
link: current?.link ?? goLimitLink(event.data.error),
both: true,
freeAt: resetAt(event),
}))
setNow(Date.now())
setOpen(true)
}
}),
)

const time = (at: number | undefined) => (at === undefined ? undefined : formatLimitReset(language.locale(), at, 0))

const left = (at: number | undefined) => {
if (at === undefined) return undefined
const minutes = Math.max(1, Math.round((at - now()) / 60_000))
const hours = Math.floor(minutes / 60)

return hours > 0
? language.t("session.goLimit.inHours", { hours, minutes: minutes % 60 })
: language.t("session.goLimit.inMinutes", { minutes })
}

// How much of Go's window is still to wait: the ring empties as the reset gets closer.
const remaining = createMemo(() => {
const at = limit()?.goAt

return at === undefined ? 1 : Math.max(0, Math.min(1, (at - now()) / GO_WINDOW_MS))
})

const text = createMemo(() => {
const current = limit()

if (!current) return { title: "", detail: "" }
const go = time(current.goAt)
const free = time(current.freeAt)

if (current.both) {
const detail = () => {
if (go && free) return language.t("session.goLimit.both.detail", { go, free })

if (go) return language.t("session.goLimit.both.detailGo", { go })

return ""
}

return { title: language.t("session.goLimit.both.title"), detail: detail() }
}

return {
title: language.t("session.goLimit.title"),
detail: go ? language.t("session.goLimit.detail", { time: go, left: left(current.goAt) ?? "" }) : "",
}
})

const both = () => limit()?.both === true

const freeModel = () => props.selection()?.list().find(isFreeModel)

const switchToFree = () => {
const selection = props.selection()
const model = freeModel()

if (!selection || !model) return
selection.set({ providerID: model.provider.id, modelID: model.id }, { recent: true })
setOpen(false)
}

const useCredits = () => {
const current = limit()

if (current) platform.openExternal(current.link)
}

return (
<Grow when={open() && limit()} fade class="relative z-0">
<div
data-component="session-go-limit-tray"
data-state={both() ? "both" : "go"}
role="status"
class="relative z-0 -mb-3 flex items-center gap-2.5 rounded-xl bg-v2-background-bg-base ps-3 pe-1.5 pt-1.5 pb-[18px] shadow-[inset_0_0_0_0.5px_var(--v2-border-border-base)]"
>
<svg class="size-4 shrink-0 -rotate-90" viewBox="0 0 16 16" aria-hidden="true">
<circle cx="8" cy="8" r="6" fill="none" stroke-width="2" class="stroke-v2-border-border-base" />
<circle
cx="8"
cy="8"
r="6"
fill="none"
stroke-width="2"
stroke-linecap="round"
pathLength="100"
stroke-dasharray={`${(remaining() * 100).toFixed(1)} 100`}
class="stroke-v2-text-text-muted"
/>
</svg>
<div class="min-w-0 flex-1 truncate text-[13px] font-[440] leading-[var(--line-height-compact)] text-v2-text-text-muted [font-variant-numeric:tabular-nums]">
<span class="font-[530] text-v2-text-text-base">{text().title}</span>
<Show when={text().detail}> {text().detail}</Show>
</div>
<div class="flex shrink-0 items-center gap-1">
<Button
data-action="session-go-limit-credits"
type="button"
size="small"
variant={both() ? "contrast" : "ghost-faint"}
class="![font-weight:530]"
onClick={useCredits}
>
{language.t("session.goLimit.useCredits")}
</Button>
<Show when={!both() && freeModel()}>
<Button
data-action="session-go-limit-free"
type="button"
size="small"
variant="contrast"
class="![font-weight:530]"
onClick={switchToFree}
>
{language.t("session.goLimit.switchFree")}
</Button>
</Show>
<IconButton
data-action="session-go-limit-dismiss"
type="button"
size="small"
variant="ghost-muted"
icon={<Icon name="outline-xmark" />}
aria-label={language.t("common.dismiss")}
onClick={() => setOpen(false)}
/>
</div>
</div>
</Grow>
)
}
Loading
Loading