fix: show the Go usage limit's reset time, Go page link and a composer tray (#60284)

Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
This commit is contained in:
Dax 2026-10-29 13:02:57 -04:00 • committed by GitHub
parent d3ad3da99e
commit d9ecbbc874
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 357 additions and 64 deletions

View file

@ -107,7 +107,12 @@ test("the free usage limit opens the Go dialog once, leaves a readable notice, a
id: "opencode",
name: "OpenCode",
models: {
"big-pickle": { id: "big-pickle", name: "Big Pickle", cost: { input: 0, output: 0 }, limit: { context: 200_000 } },
"big-pickle": {
id: "big-pickle",
name: "Big Pickle",
cost: { input: 0, output: 0 },
limit: { context: 200_000 },
},
},
},
...(state.authorized
@ -117,7 +122,12 @@ test("the free usage limit opens the Go dialog once, leaves a readable notice, a
integrationID: "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 } },
"glm-5-go": {
id: "glm-5-go",
name: "GLM-5 Go",
cost: { input: 1, output: 1 },
limit: { context: 200_000 },
},
},
},
]
@ -131,7 +141,9 @@ test("the free usage limit opens the Go dialog once, leaves a readable notice, a
id: "opencode",
name: "OpenCode Console",
methods: [{ id: "device", type: "oauth", label: "OpenCode Console account" }],
connections: state.authorized ? [{ type: "credential", id: "cred_console", label: "Anomaly", method: "oauth" }] : [],
connections: state.authorized
? [{ type: "credential", id: "cred_console", label: "Anomaly", method: "oauth" }]
: [],
},
{
id: "opencode-go",
@ -261,62 +273,116 @@ 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 restores the prompt and leaves a muted notice without a dialog", async ({ page }) => {
await page.clock.setFixedTime(T0)
const sessionID = "ses_go_limit"
const model = { id: "glm-5-go", providerID: "opencode-go" }
for (const action of ["tray", "picker"] as const) {
test(`the Go usage limit restores the prompt and dismisses the tray when switching models through the ${action}`, async ({
page,
context,
}) => {
await page.clock.setFixedTime(T0)
await context.route("https://opencode.ai/**", (route) => route.fulfill({ body: "Go" }))
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,
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 } },
],
}),
})
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()
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 }
// The refusal OpenCode Console sends a Go subscriber at a limit, as core records it.
const body = {
type: "error",
error: { type: "GoUsageLimitError", message: "Go usage limit exceeded" },
metadata: { workspace: "wrk_go_limit", limitName: "5 hour" },
}
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,
await view.push([
{
id: "evt_go_limit_1",
created: T0 + 2,
type: "session.step.failed",
location: { directory: view.directory },
durable: { aggregateID: sessionID, seq: 1, version: 1 },
data: {
sessionID,
assistantMessageID: "msg_go_assistant",
error: {
type: "provider.go-limit",
message: "OpenCode Go 5 hour usage limit reached.",
status: 429,
response: { body: JSON.stringify(body) },
retryAfterMs: 3_600_000,
},
},
},
},
])
])
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)
await expect(page.getByRole("dialog")).toHaveCount(0)
})
const tray = page.locator('[data-component="session-go-limit-tray"]')
await expect(tray).toContainText(/Go 5 hour usage limit reached\. It resets at .+?\./)
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 5 hour usage limit reached.")).toHaveCount(0)
await expect(page.getByRole("dialog")).toHaveCount(0)
// Extra Usage is turned on from the workspace's Go page in OpenCode Console.
const popup = page.waitForEvent("popup")
await tray.getByRole("button", { name: "Turn on Extra Usage", exact: true }).click()
await expect(await popup).toHaveURL("https://opencode.ai/console/wrk_go_limit/go")
if (action === "tray") await tray.getByRole("button", { name: "Switch to a free model", exact: true }).click()
if (action === "picker") {
await page.locator('[data-action="composer-model"]').click()
const picker = page.getByRole("dialog", { name: "Select model", exact: true })
await picker.getByRole("combobox", { name: "Search models", exact: true }).fill("Big Pickle")
await picker.getByRole("option", { name: /Big Pickle/ }).click()
}
await expect(page.locator('[data-action="composer-model"]')).toContainText("Big Pickle")
await expect(tray).toHaveCount(0)
await expect(view.editor).toHaveText("Refactor the parser")
})
}
test("ChatGPT's usage limit opens the ChatGPT usage dialog", async ({ page }) => {
await page.clock.setFixedTime(T0)

View file

@ -602,6 +602,11 @@ 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 usage limit reached.",
"session.goLimit.titleNamed": "Go {{name}} usage limit reached.",
"session.goLimit.reset": "It resets at {{time}}.",
"session.goLimit.extraUsage": "Turn on Extra Usage",
"session.goLimit.switchFree": "Switch to a free model",
"session.queue.count.one": "{{count}} queued",
"session.queue.count.other": "{{count}} queued",
"session.queue.steer": "Steer",

View file

@ -0,0 +1,137 @@
import { createEffect, createMemo, createSignal, on, onCleanup, Show } from "solid-js"
import { Option, Schema } from "effect"
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 } from "../usage-exceeded-dialogs"
import { Grow } from "./spring-height"
// OpenCode Console names the workspace and the limit (5 hour, weekly or monthly) in its Go usage limit refusal.
const decodeGoLimitBody = Schema.decodeUnknownOption(
Schema.fromJsonString(
Schema.Struct({
metadata: Schema.Struct({ workspace: Schema.optional(Schema.String), limitName: Schema.optional(Schema.String) }),
}),
),
)
type GoLimit = { name?: string; reset?: string; link: string }
function goLimit(locale: string, event: SessionStepFailed): GoLimit {
const metadata = Option.getOrUndefined(decodeGoLimitBody(event.data.error.response?.body))?.metadata
const workspace = metadata?.workspace
return {
name: metadata?.limitName,
reset: formatLimitReset(locale, event.created, event.data.error.retryAfterMs),
// The Go page is where the workspace turns on Extra Usage to keep going on its balance.
link: workspace ? `https://opencode.ai/console/${encodeURIComponent(workspace)}/go` : "https://opencode.ai/console",
}
}
// A tray that grows out from under the composer, like the queue panel, when an OpenCode Go subscriber reaches a usage
// limit: when Go is back, and how to keep going now.
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 model = createMemo(() => {
const current = props.selection()?.current()
return current && `${current.provider.id}:${current.id}`
})
createEffect(on(model, () => setLimit(undefined), { defer: true }))
onCleanup(
location().event.on("session.step.failed", (event) => {
if (!props.sessionID || event.data.sessionID !== props.sessionID) return
if (event.data.error.type === "provider.go-limit") setLimit(goLimit(language.locale(), event))
}),
)
// The next step means the user found a way to keep going, so the tray has done its job.
onCleanup(
location().event.on("session.step.started", (event) => {
if (event.data.sessionID === props.sessionID) setLimit(undefined)
}),
)
const title = () => {
const name = limit()?.name
return name ? language.t("session.goLimit.titleNamed", { name }) : language.t("session.goLimit.title")
}
const freeModel = () => props.selection()?.list().find(isFreeModel)
const switchToFree = () => {
const model = freeModel()
if (!model) return
props.selection()?.set({ providerID: model.provider.id, modelID: model.id }, { recent: true })
setLimit(undefined)
}
return (
<Grow when={limit()} fade class="relative z-0">
{(current) => (
<div
data-component="session-go-limit-tray"
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)]"
>
<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">{title()}</span>
<Show when={current.reset}>{(time) => <> {language.t("session.goLimit.reset", { time: time() })}</>}</Show>
</div>
<div class="flex shrink-0 items-center gap-1">
<Button
data-action="session-go-limit-extra-usage"
type="button"
size="small"
variant={freeModel() ? "ghost-faint" : "contrast"}
class="![font-weight:530]"
onClick={() => platform.openExternal(current.link)}
>
{language.t("session.goLimit.extraUsage")}
</Button>
<Show when={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={() => setLimit(undefined)}
/>
</div>
</div>
)}
</Grow>
)
}

View file

@ -37,6 +37,7 @@ import { useSessionFork } from "../fork"
import { SessionComposerRegion } from "./session-composer-region"
import { createSessionComposerController, type SessionComposerController } from "./controller"
import { SessionQueuePanel } from "./queue-panel"
import { SessionGoLimitTray } from "./go-limit-tray"
import { SessionActivityChips, type SessionActivityItem } from "./activity-chips"
import { SessionComposerLane, useComposerLaneAlignment } from "./composer-lane"
import { SessionTodoPill } from "./todo-pill"
@ -578,6 +579,10 @@ export function ActiveSessionComposerRegion(props: {
<SessionPluginPills session={props.view()} screen={props.screen} alignment={alignment} />
</SessionComposerLane>
<SessionQueuePanel queue={props.model.queue} hidden={props.model.region.state.blocked()} />
<SessionGoLimitTray
sessionID={props.session.identity.params.id}
selection={() => props.model.composer.model.selection}
/>
</Show>
}
composer={

View file

@ -22,7 +22,7 @@ const MAX_RETRY_AFTER_MS = 7 * 86_400_000
const CHATGPT_USAGE_LIMIT_WINDOW = 86_400_000 // 24 hrs
function formatLimitReset(locale: string, created: number, retryAfterMs: number | undefined) {
export function formatLimitReset(locale: string, created: number, retryAfterMs: number | undefined) {
if (
retryAfterMs === undefined ||
!Number.isFinite(retryAfterMs) ||

View file

@ -29,8 +29,6 @@ const FREE_USAGE_LIMIT_MESSAGE =
"Free usage limit reached. Connect a provider or subscribe to OpenCode Go to keep going, or switch models."
const BUDGET_LIMIT_MESSAGE = "Workspace usage limit reached. Model use is paused until the limit resets."
const MODEL_BUDGET_LIMIT_MESSAGE = "You've reached the spend limit for this model. Switch models to keep going."
const GO_USAGE_LIMIT_MESSAGE =
"OpenCode Go usage limit reached. To keep using this model, enable spending from your available balance in the Console."
export function toSessionError(cause: unknown): SessionError.Error {
if (cause instanceof AIError) {
@ -46,8 +44,7 @@ export function toSessionError(cause: unknown): SessionError.Error {
if (cause.reason.body?.includes("FreeUsageLimitError"))
return { ...providerError("provider.free-limit", cause.reason), message: FREE_USAGE_LIMIT_MESSAGE }
if (cause.reason.body?.includes("BudgetLimitError")) return budgetLimit(cause.reason)
if (cause.reason.body?.includes("GoUsageLimitError"))
return { ...providerError("provider.go-limit", cause.reason), message: GO_USAGE_LIMIT_MESSAGE }
if (cause.reason.body?.includes("GoUsageLimitError")) return goLimit(cause.reason)
return providerError("provider.quota", cause.reason)
case "ContentPolicy":
return providerError("provider.content-filter", cause.reason)
@ -116,6 +113,15 @@ function budgetLimit(reason: AIError["reason"]): SessionError.Error {
return { ...providerError("provider.budget-limit", reason), message: BUDGET_LIMIT_MESSAGE }
}
// OpenCode Console names the Go limit that refused the request (5 hour, weekly or monthly) in its envelope's metadata.
function goLimit(reason: AIError["reason"]): SessionError.Error {
const name = /"limitName"\s*:\s*"([^"]+)"/.exec(reason.body ?? "")?.[1]
return {
...providerError("provider.go-limit", reason),
message: `OpenCode Go ${name ? `${name} ` : ""}usage limit reached. Switch to a free model, or turn on Extra Usage on the Go page in OpenCode Console to keep going.`,
}
}
function providerError(type: string, reason: AIError["reason"]): SessionError.Error {
const status = reason.http?.status
const retryAfterMs = "retryAfterMs" in reason ? reason.retryAfterMs : undefined

View file

@ -123,7 +123,7 @@ describe("toSessionError", () => {
expect(toSessionError(go)).toEqual({
type: "provider.go-limit",
message:
"OpenCode Go usage limit reached. To keep using this model, enable spending from your available balance in the Console.",
"OpenCode Go 5 hour usage limit reached. Switch to a free model, or turn on Extra Usage on the Go page in OpenCode Console to keep going.",
status: 429,
response: { body: JSON.stringify(goBody) },
retryAfterMs: 120_000,

View file

@ -19,6 +19,7 @@ import {
import { OpenAIChat } from "@opencode/ai/protocols/openai-chat"
import { AnthropicMessages, OpenAIResponses } from "@opencode/ai/protocols"
import { TestLLM, compileRequest } from "@opencode/ai/testing"
import { RequestExecutor } from "@opencode/ai/route"
import type { SessionHooks } from "@opencode/plugin/effect/session"
import { Database } from "@opencode/core/database/database"
import { makeLocationNode } from "@opencode/util/effect/app-node"
@ -6447,6 +6448,44 @@ describe("SessionRunnerLLM", () => {
expect(yield* recordedEventTypes(sessionID)).not.toContain("session.retry.scheduled.1")
})
scenario("records OpenCode Console's Go usage limit 429 as a typed step failure without retrying", function* (s) {
// The exact refusal Console sends a Go subscriber at a limit.
const body = JSON.stringify({
type: "error",
error: { type: "GoUsageLimitError", message: "Go usage limit exceeded" },
metadata: { workspace: "wrk_go_limit", limitName: "5 hour" },
})
const failure = RequestExecutor.httpFailure({
message: "Go usage limit exceeded",
url: "https://opencode.ai/inference/go/openai/v1/chat/completions",
status: 429,
responseHeaders: { "retry-after": "3600" },
responseBody: body,
})
yield* s.llm.push(Stream.fail(failure))
expect(yield* s.runPrompt("Hit the Go limit").pipe(Effect.flip)).toBe(failure)
expect(s.requests).toHaveLength(1)
expect(yield* recordedEventTypes(sessionID)).not.toContain("session.retry.scheduled.1")
const assistant = requireAssistant(yield* s.context)
expect(yield* recordedStepSettlementEvents(sessionID, assistant.id)).toMatchObject([
{ type: "session.step.started.1" },
{
type: "session.step.failed.1",
data: {
error: {
type: "provider.go-limit",
message:
"OpenCode Go 5 hour usage limit reached. Switch to a free model, or turn on Extra Usage on the Go page in OpenCode Console to keep going.",
status: 429,
response: { body },
retryAfterMs: 3_600_000,
},
},
},
])
})
scenario("settles malformed streamed tool input before the provider failure", function* (s) {
const failure = new AIError({
reason: new InvalidProviderOutputError({ message: "Invalid JSON input for tool call echo" }),

View file

@ -7,6 +7,7 @@ import { useLocal } from "../context/local"
import { useLocation } from "../context/location"
import { useTheme } from "../context/theme"
import { SplitBorder } from "../ui/border"
import { Link } from "../ui/link"
import { DIALOG_LIST_INSET, DialogHeader, DialogLayout, useDialog } from "../ui/dialog"
import { DialogSelect } from "../ui/dialog-select"
import { useToast } from "../ui/toast"
@ -15,6 +16,8 @@ import {
formatReset,
limitScope,
modelUsage,
goLimitLink,
goLimitName,
refusalReset,
retryReset,
servedByConsole,
@ -263,9 +266,10 @@ export function useLimitNotice(message: () => SessionMessageAssistant) {
value.model.id
if (error.type === "provider.go-limit")
return {
title: "OpenCode Go usage limit reached",
body: `${resets}Switch model with`,
title: `OpenCode Go ${[goLimitName(error), "usage limit reached"].filter(Boolean).join(" ")}`,
body: `${resets}Switch to a free model with`,
key: shortcut() ?? "/models",
link: { label: "Or turn on Extra Usage on your Go page to keep going now:", href: goLimitLink(error) },
}
if (error.type === "provider.model-budget-limit")
return { title: `${name} usage limit reached`, body: `${resets}Switch model with`, key: shortcut() ?? "/models" }
@ -284,7 +288,9 @@ export function useLimitNotice(message: () => SessionMessageAssistant) {
})
}
export function UsageLimitNotice(props: { notice: { title: string; body: string; key?: string } }) {
export function UsageLimitNotice(props: {
notice: { title: string; body: string; key?: string; link?: { label: string; href: string } }
}) {
const theme = useTheme()
return (
<box
@ -308,6 +314,16 @@ export function UsageLimitNotice(props: { notice: { title: string; body: string;
)}
</Show>
</text>
<Show when={props.notice.link}>
{(link) => (
<box paddingTop={1}>
<text fg={theme.text.muted} wrapMode="word">
{link().label}
</text>
<Link href={link().href} fg={theme.text.base} />
</box>
)}
</Show>
</box>
)
}

View file

@ -105,6 +105,17 @@ export function retryReset(error: { readonly retryAfterMs?: number } | undefined
return Number.isNaN(new Date(at).getTime()) ? undefined : at
}
/** Which Go limit refused a request (5 hour, weekly or monthly), as OpenCode Console names it in the metadata. */
export function goLimitName(error: { readonly response?: { readonly body: string } } | undefined) {
return /"limitName"\s*:\s*"([^"]+)"/.exec(error?.response?.body ?? "")?.[1]
}
/** The OpenCode Console Go page for the workspace a Go usage limit names, where it can turn on Extra Usage. */
export function goLimitLink(error: { readonly response?: { readonly body: string } } | undefined) {
const workspace = /"workspace"\s*:\s*"(wrk_\w+)"/.exec(error?.response?.body ?? "")?.[1]
return workspace ? `https://opencode.ai/console/${workspace}/go` : "https://opencode.ai/console"
}
/**
* Who spends from a limit and, when it pools spend across models, which ones. A limit on each model separately names
* only who spends from it.

View file

@ -2562,8 +2562,15 @@ test("Go usage limit restores the prompt and explains the limit with its reset t
}
const error = {
type: "provider.go-limit",
message: "OpenCode Go usage limit reached.",
message: "OpenCode Go 5 hour usage limit reached.",
status: 429,
response: {
body: JSON.stringify({
type: "error",
error: { type: "GoUsageLimitError", message: "Go usage limit exceeded" },
metadata: { workspace: "wrk_go_limit", limitName: "5 hour" },
}),
},
retryAfterMs: 3_600_000,
}
await using setup = await createAppFixture({
@ -2605,12 +2612,13 @@ test("Go usage limit restores the prompt and explains the limit with its reset t
})
await setup.waitForFrame(
(frame) =>
frame.includes("OpenCode Go usage limit reached") &&
frame.includes("OpenCode Go 5 hour usage limit reached") &&
frame.includes("Resets ") &&
frame.split("Refactor the parser").length === 3,
)
const frame = setup.captureCharFrame()
expect(frame).toContain("Switch model with")
expect(frame).toContain("Switch to a free model with")
expect(frame).toContain("https://opencode.ai/console/wrk_go_limit/go")
expect(frame).not.toContain("Error:")
expect(frame).not.toContain("Free limit reached")
})