From f5d0371efefcc05cd296b0d65b088123dee860dd Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 7 May 2026 16:53:24 -0500 Subject: [PATCH] tui: go plan payg msg (#26248) --- ...-go-upsell.tsx => dialog-retry-action.tsx} | 94 +++++++------- .../src/cli/cmd/tui/routes/session/index.tsx | 6 +- packages/opencode/src/cli/cmd/tui/ui/link.tsx | 4 + packages/opencode/src/session/processor.ts | 1 + packages/opencode/src/session/retry.ts | 117 ++++++++++++++---- packages/opencode/src/session/status.ts | 8 ++ packages/opencode/test/session/retry.test.ts | 80 ++++++++++-- .../test/session/schema-decoding.test.ts | 15 ++- packages/sdk/js/src/v2/gen/types.gen.ts | 6 + script/zen-limit-server.ts | 37 ++++++ 10 files changed, 285 insertions(+), 83 deletions(-) rename packages/opencode/src/cli/cmd/tui/component/{dialog-go-upsell.tsx => dialog-retry-action.tsx} (58%) create mode 100644 script/zen-limit-server.ts diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-go-upsell.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-retry-action.tsx similarity index 58% rename from packages/opencode/src/cli/cmd/tui/component/dialog-go-upsell.tsx rename to packages/opencode/src/cli/cmd/tui/component/dialog-retry-action.tsx index 3a1fd97b2c..9dad1b4561 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-go-upsell.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-retry-action.tsx @@ -8,32 +8,36 @@ import { GoLogo } from "./logo" import { BgPulse, type BgPulseMask } from "./bg-pulse" import { useBindings } from "../keymap" -const GO_URL = "https://opencode.ai/go" const PAD_X = 3 const PAD_TOP_OUTER = 1 -export type DialogGoUpsellProps = { +export type DialogRetryActionProps = { + title: string + message: string + label: string + link?: string onClose?: (dontShowAgain?: boolean) => void } -function subscribe(props: DialogGoUpsellProps, dialog: ReturnType) { - open(GO_URL).catch(() => {}) +function runAction(props: DialogRetryActionProps, dialog: ReturnType) { + if (props.link) open(props.link).catch(() => {}) props.onClose?.() dialog.clear() } -function dismiss(props: DialogGoUpsellProps, dialog: ReturnType) { +function dismiss(props: DialogRetryActionProps, dialog: ReturnType) { props.onClose?.(true) dialog.clear() } -export function DialogGoUpsell(props: DialogGoUpsellProps) { +export function DialogRetryAction(props: DialogRetryActionProps) { const dialog = useDialog() const { theme } = useTheme() const fg = selectedForeground(theme) - const [selected, setSelected] = createSignal<"dismiss" | "subscribe">("subscribe") + const [selected, setSelected] = createSignal<"dismiss" | "action">("action") const [center, setCenter] = createSignal<{ x: number; y: number } | undefined>() const [masks, setMasks] = createSignal([]) + const showGoTreatment = () => props.link === "https://opencode.ai/go" let content: BoxRenderable | undefined let logoBox: BoxRenderable | undefined let headingBox: BoxRenderable | undefined @@ -41,11 +45,13 @@ export function DialogGoUpsell(props: DialogGoUpsellProps) { let buttonsBox: BoxRenderable | undefined const sync = () => { - if (!content || !logoBox) return - setCenter({ - x: logoBox.x - content.x + logoBox.width / 2, - y: logoBox.y - content.y + logoBox.height / 2 + PAD_TOP_OUTER, - }) + if (!content) return + if (logoBox) { + setCenter({ + x: logoBox.x - content.x + logoBox.width / 2, + y: logoBox.y - content.y + logoBox.height / 2 + PAD_TOP_OUTER, + }) + } const next: BgPulseMask[] = [] const baseY = PAD_TOP_OUTER for (const b of [headingBox, descBox, buttonsBox]) { @@ -75,20 +81,20 @@ export function DialogGoUpsell(props: DialogGoUpsellProps) { bindings: [ { key: "left", - cmd: () => setSelected((value) => (value === "subscribe" ? "dismiss" : "subscribe")), + cmd: () => setSelected((value) => (value === "action" ? "dismiss" : "action")), }, { key: "right", - cmd: () => setSelected((value) => (value === "subscribe" ? "dismiss" : "subscribe")), + cmd: () => setSelected((value) => (value === "action" ? "dismiss" : "action")), }, { key: "tab", - cmd: () => setSelected((value) => (value === "subscribe" ? "dismiss" : "subscribe")), + cmd: () => setSelected((value) => (value === "action" ? "dismiss" : "action")), }, { key: "return", cmd: () => { - if (selected() === "subscribe") subscribe(props, dialog) + if (selected() === "action") runAction(props, dialog) else dismiss(props, dialog) }, }, @@ -97,33 +103,34 @@ export function DialogGoUpsell(props: DialogGoUpsellProps) { return ( (content = item)}> - - - - + {showGoTreatment() ? ( + + + + ) : null} + (headingBox = item)} flexDirection="row" justifyContent="space-between"> - Free limit reached + {props.title} dialog.clear()}> esc (descBox = item)} gap={0}> - - Subscribe to - - OpenCode Go - - for reliable access to the - - best open-source models, starting at $5/month. + {props.message} - - (logoBox = item)}> - - - + + {showGoTreatment() ? ( + (logoBox = item)} alignItems="center"> + + + ) : null} + {props.link ? ( + + + + ) : null} (buttonsBox = item)} flexDirection="row" justifyContent="space-between"> setSelected("subscribe")} - onMouseUp={() => subscribe(props, dialog)} + backgroundColor={selected() === "action" ? theme.primary : RGBA.fromInts(0, 0, 0, 0)} + onMouseOver={() => setSelected("action")} + onMouseUp={() => runAction(props, dialog)} > - subscribe + {props.label} @@ -160,10 +167,13 @@ export function DialogGoUpsell(props: DialogGoUpsellProps) { ) } -DialogGoUpsell.show = (dialog: DialogContext) => { +DialogRetryAction.show = ( + dialog: DialogContext, + props: Pick, +) => { return new Promise((resolve) => { dialog.replace( - () => resolve(dontShow ?? false)} />, + () => resolve(dontShow ?? false)} />, () => resolve(false), ) }) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 9ba300ea14..d2b50c32f8 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -85,7 +85,7 @@ import { UI } from "@/cli/ui.ts" import { useTuiConfig } from "../../context/tui-config" import { getScrollAcceleration } from "../../util/scroll" import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime" -import { DialogGoUpsell } from "../../component/dialog-go-upsell" +import { DialogRetryAction } from "../../component/dialog-retry-action" import { SessionRetry } from "@/session/retry" import { getRevertDiffFiles } from "../../util/revert-diff" import { useCommandPalette } from "../../context/command-palette" @@ -260,7 +260,7 @@ export function Session() { event.on("session.status", (evt) => { if (evt.properties.sessionID !== route.sessionID) return if (evt.properties.status.type !== "retry") return - if (evt.properties.status.message !== SessionRetry.GO_UPSELL_MESSAGE) return + if (!evt.properties.status.action) return if (dialog.stack.length > 0) return const seen = kv.get(GO_UPSELL_LAST_SEEN_AT) @@ -268,7 +268,7 @@ export function Session() { if (kv.get(GO_UPSELL_DONT_SHOW)) return - void DialogGoUpsell.show(dialog).then((dontShowAgain) => { + void DialogRetryAction.show(dialog, evt.properties.status.action).then((dontShowAgain) => { if (dontShowAgain) kv.set(GO_UPSELL_DONT_SHOW, true) kv.set(GO_UPSELL_LAST_SEEN_AT, Date.now()) }) diff --git a/packages/opencode/src/cli/cmd/tui/ui/link.tsx b/packages/opencode/src/cli/cmd/tui/ui/link.tsx index 3b328e478d..01c4b6e713 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/link.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/link.tsx @@ -6,6 +6,8 @@ export interface LinkProps { href: string children?: JSX.Element | string fg?: RGBA + width?: number | "auto" | `${number}%` + wrapMode?: "word" | "none" } /** @@ -18,6 +20,8 @@ export function Link(props: LinkProps) { return ( { open(props.href).catch(() => {}) }} diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index f22da92927..66a2d47975 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -717,6 +717,7 @@ export const layer: Layer.Layer< type: "retry", attempt: info.attempt, message: info.message, + action: info.action, next: info.next, }) }, diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index e81e197375..6a14dfc35b 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -5,9 +5,19 @@ import { iife } from "@/util/iife" export type Err = ReturnType -// This exported message is shared with the TUI upsell detector. Matching on a -// literal error string kind of sucks, but it is the simplest for now. -export const GO_UPSELL_MESSAGE = "Free usage exceeded, subscribe to Go https://opencode.ai/go" +export const GO_UPSELL_MESSAGE = "Free usage exceeded, subscribe to Go" +export const PAYG_UPSELL_MESSAGE = "Go usage exceeded, enable PAYG" +export const GO_UPSELL_URL = "https://opencode.ai/go" + +export type Retryable = { + message: string + action?: { + title: string + message: string + label: string + link?: string + } +} export const RETRY_INITIAL_DELAY = 2000 export const RETRY_BACKOFF_FACTOR = 2 @@ -59,8 +69,49 @@ export function retryable(error: Err) { // 5xx errors are transient server failures and should always be retried, // even when the provider SDK doesn't explicitly mark them as retryable. if (!error.data.isRetryable && !(status !== undefined && status >= 500)) return undefined - if (error.data.responseBody?.includes("FreeUsageLimitError")) return GO_UPSELL_MESSAGE - return error.data.message.includes("Overloaded") ? "Provider is overloaded" : error.data.message + if (error.data.responseBody?.includes("FreeUsageLimitError")) { + return { + message: GO_UPSELL_MESSAGE, + action: { + title: "Free limit reached", + message: + "Subscribe to OpenCode Go for reliable access to the best open-source models, starting at $5/month.", + label: "subscribe", + link: GO_UPSELL_URL, + }, + } + } + if (error.data.responseBody?.includes("GoUsageLimitError")) { + const body = parseJSON(error.data.responseBody) + const workspace = str(body?.metadata?.workspace) + const limit = str(body?.metadata?.limit) + const resetAt = num(body?.metadata?.resetAt) + const resetIn = iife(() => { + if (resetAt === undefined) return "" + const seconds = Math.max(0, Math.ceil(resetAt)) + const days = Math.floor(seconds / 86_400) + const hours = Math.floor((seconds % 86_400) / 3_600) + const minutes = Math.ceil((seconds % 3_600) / 60) + const unit = (value: number, name: string) => `${value} ${name}${value === 1 ? "" : "s"}` + + if (days > 0) return hours > 0 ? `${unit(days, "day")} ${unit(hours, "hour")}` : unit(days, "day") + if (hours > 0) return minutes > 0 ? `${unit(hours, "hour")} ${unit(minutes, "minute")}` : unit(hours, "hour") + return minutes > 0 ? unit(minutes, "minute") : "less than a minute" + }) + return { + message: PAYG_UPSELL_MESSAGE, + action: { + title: "Go limit reached", + message: + limit && resetIn + ? `You hit your ${limit} limit. It will reset in ${resetIn}. You can also enable pay-as-you-go.` + : "Enable pay-as-you-go to keep using Go models after your subscription quota is used.", + label: "enable PAYG", + ...(workspace ? { link: `https://opencode.ai/workspace/${workspace}/go` } : {}), + }, + } + } + return { message: error.data.message.includes("Overloaded") ? "Provider is overloaded" : error.data.message } } // Check for rate limit patterns in plain text error messages @@ -72,50 +123,66 @@ export function retryable(error: Err) { lower.includes("rate limit") || lower.includes("too many requests") ) { - return msg + return { message: msg } } } - const json = iife(() => { - try { - if (typeof error.data?.message === "string") { - const parsed = JSON.parse(error.data.message) - return parsed - } - - return JSON.parse(error.data.message) - } catch { - return undefined - } - }) + const json = parseJSON(error.data?.message) if (!json || typeof json !== "object") return undefined const code = typeof json.code === "string" ? json.code : "" if (json.type === "error" && json.error?.type === "too_many_requests") { - return "Too Many Requests" + return { message: "Too Many Requests" } } if (code.includes("exhausted") || code.includes("unavailable")) { - return "Provider is overloaded" + return { message: "Provider is overloaded" } } if (json.type === "error" && typeof json.error?.code === "string" && json.error.code.includes("rate_limit")) { - return "Rate Limited" + return { message: "Rate Limited" } } return undefined } +function str(value: unknown) { + if (value === undefined || value === null) return "" + return String(value) +} + +function num(value: unknown) { + const parsed = Number.parseFloat(str(value)) + if (Number.isNaN(parsed)) return undefined + return parsed +} + +function parseJSON(value: unknown) { + return iife(() => { + try { + if (typeof value !== "string") return undefined + return JSON.parse(value) + } catch { + return undefined + } + }) +} + export function policy(opts: { parse: (error: unknown) => Err - set: (input: { attempt: number; message: string; next: number }) => Effect.Effect + set: (input: { attempt: number; message: string; action?: Retryable["action"]; next: number }) => Effect.Effect }) { return Schedule.fromStepWithMetadata( Effect.succeed((meta: Schedule.InputMetadata) => { const error = opts.parse(meta.input) - const message = retryable(error) - if (!message) return Cause.done(meta.attempt) + const retry = retryable(error) + if (!retry) return Cause.done(meta.attempt) return Effect.gen(function* () { const wait = delay(meta.attempt, MessageV2.APIError.isInstance(error) ? error : undefined) const now = yield* Clock.currentTimeMillis - yield* opts.set({ attempt: meta.attempt, message, next: now + wait }) + yield* opts.set({ + attempt: meta.attempt, + message: retry.message, + action: retry.action, + next: now + wait, + }) return [meta.attempt, Duration.millis(wait)] as [number, Duration.Duration] }) }), diff --git a/packages/opencode/src/session/status.ts b/packages/opencode/src/session/status.ts index a0e57afc22..1d6e96d935 100644 --- a/packages/opencode/src/session/status.ts +++ b/packages/opencode/src/session/status.ts @@ -15,6 +15,14 @@ export const Info = Schema.Union([ type: Schema.Literal("retry"), attempt: NonNegativeInt, message: Schema.String, + action: Schema.optional( + Schema.Struct({ + title: Schema.String, + message: Schema.String, + label: Schema.String, + link: Schema.optional(Schema.String), + }), + ), next: NonNegativeInt, }), Schema.Struct({ diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 105c772d97..f65c403e68 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -118,12 +118,12 @@ describe("session.retry.delay", () => { describe("session.retry.retryable", () => { test("maps too_many_requests json messages", () => { const error = wrap(JSON.stringify({ type: "error", error: { type: "too_many_requests" } })) - expect(SessionRetry.retryable(error)).toBe("Too Many Requests") + expect(SessionRetry.retryable(error)).toEqual({ message: "Too Many Requests" }) }) test("maps overloaded provider codes", () => { const error = wrap(JSON.stringify({ code: "resource_exhausted" })) - expect(SessionRetry.retryable(error)).toBe("Provider is overloaded") + expect(SessionRetry.retryable(error)).toEqual({ message: "Provider is overloaded" }) }) test("does not retry unknown json messages", () => { @@ -146,19 +146,19 @@ describe("session.retry.retryable", () => { const msg = "Upstream error from Alibaba: Request rate increased too quickly. To ensure system stability, please adjust your client logic to scale requests more smoothly over time." const error = wrap(msg) - expect(SessionRetry.retryable(error)).toBe(msg) + expect(SessionRetry.retryable(error)).toEqual({ message: msg }) }) test("retries plain text rate limit errors", () => { const msg = "Rate limit exceeded, please try again later" const error = wrap(msg) - expect(SessionRetry.retryable(error)).toBe(msg) + expect(SessionRetry.retryable(error)).toEqual({ message: msg }) }) test("retries too many requests in plain text", () => { const msg = "Too many requests, please slow down" const error = wrap(msg) - expect(SessionRetry.retryable(error)).toBe(msg) + expect(SessionRetry.retryable(error)).toEqual({ message: msg }) }) test("does not retry context overflow errors", () => { @@ -180,7 +180,7 @@ describe("session.retry.retryable", () => { }).toObject(), ) - expect(SessionRetry.retryable(error)).toBe("Internal server error") + expect(SessionRetry.retryable(error)).toEqual({ message: "Internal server error" }) }) test("retries 502 bad gateway errors", () => { @@ -192,7 +192,7 @@ describe("session.retry.retryable", () => { }).toObject(), ) - expect(SessionRetry.retryable(error)).toBe("Bad gateway") + expect(SessionRetry.retryable(error)).toEqual({ message: "Bad gateway" }) }) test("retries 503 service unavailable errors", () => { @@ -204,7 +204,7 @@ describe("session.retry.retryable", () => { }).toObject(), ) - expect(SessionRetry.retryable(error)).toBe("Service unavailable") + expect(SessionRetry.retryable(error)).toEqual({ message: "Service unavailable" }) }) test("does not retry 4xx errors when isRetryable is false", () => { @@ -230,7 +230,65 @@ describe("session.retry.retryable", () => { const retryable = SessionRetry.retryable(error) expect(retryable).toBeDefined() - expect(retryable).toBe("Response decompression failed") + expect(retryable).toEqual({ message: "Response decompression failed" }) + }) + + test("maps free limits to Go upsell action", () => { + const error = MessageV2.APIError.Schema.parse( + new MessageV2.APIError({ + message: "Free usage exceeded", + isRetryable: true, + statusCode: 429, + responseBody: JSON.stringify({ + type: "error", + error: { type: "FreeUsageLimitError", message: "Free usage exceeded" }, + }), + }).toObject(), + ) + + expect(SessionRetry.retryable(error)).toEqual({ + message: SessionRetry.GO_UPSELL_MESSAGE, + action: { + title: "Free limit reached", + message: + "Subscribe to OpenCode Go for reliable access to the best open-source models, starting at $5/month.", + label: "subscribe", + link: SessionRetry.GO_UPSELL_URL, + }, + }) + }) + + test("maps Go subscription limits to workspace PAYG upsell", () => { + const error = MessageV2.APIError.Schema.parse( + new MessageV2.APIError({ + message: "Subscription quota exceeded. You can continue using free models.", + isRetryable: true, + statusCode: 429, + responseBody: JSON.stringify({ + type: "error", + error: { + type: "GoUsageLimitError", + message: "Subscription quota exceeded. You can continue using free models.", + }, + metadata: { + workspace: "wrk_01K6XGM22R6FM8JVABE9XDQXGH", + limit: "5 hour", + resetAt: 19_380, + }, + }), + }).toObject(), + ) + + expect(SessionRetry.retryable(error)).toEqual({ + message: SessionRetry.PAYG_UPSELL_MESSAGE, + action: { + title: "Go limit reached", + message: + "You hit your 5 hour limit. It will reset in 5 hours 23 minutes. You can also enable pay-as-you-go.", + label: "enable PAYG", + link: "https://opencode.ai/workspace/wrk_01K6XGM22R6FM8JVABE9XDQXGH/go", + }, + }) }) }) @@ -283,7 +341,7 @@ describe("session.message-v2.fromError", () => { const retryable = SessionRetry.retryable(error) expect(retryable).toBeDefined() - expect(retryable).toBe("Connection reset by server") + expect(retryable).toEqual({ message: "Connection reset by server" }) }) test("marks OpenAI 404 status codes as retryable", () => { @@ -321,6 +379,6 @@ describe("session.message-v2.fromError", () => { expect(MessageV2.APIError.isInstance(result)).toBe(true) if (!MessageV2.APIError.isInstance(result)) throw new Error("expected APIError") expect(result.data.isRetryable).toBe(true) - expect(SessionRetry.retryable(result)).toBe("An error occurred while processing your request.") + expect(SessionRetry.retryable(result)).toEqual({ message: "An error occurred while processing your request." }) }) }) diff --git a/packages/opencode/test/session/schema-decoding.test.ts b/packages/opencode/test/session/schema-decoding.test.ts index abe99dddc7..8bb94bdd8c 100644 --- a/packages/opencode/test/session/schema-decoding.test.ts +++ b/packages/opencode/test/session/schema-decoding.test.ts @@ -230,8 +230,19 @@ describe("SessionStatus.Info", () => { expect(SessionStatus.Info.zod.parse({ type: "idle" })).toEqual({ type: "idle" }) }) - test("retry carries attempt/message/next", () => { - const input = { type: "retry" as const, attempt: 1, message: "transient", next: 500 } + test("retry carries attempt/message/action/next", () => { + const input = { + type: "retry" as const, + attempt: 1, + message: "transient", + action: { + title: "Free limit reached", + message: "Subscribe to OpenCode Go.", + label: "subscribe", + link: "https://opencode.ai/go", + }, + next: 500, + } expect(decode(input)).toEqual(input) expect(SessionStatus.Info.zod.parse(input)).toEqual(input) }) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 175fe69e66..5a330c37b6 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -266,6 +266,12 @@ export type SessionStatus = type: "retry" attempt: number message: string + action?: { + title: string + message: string + label: string + link?: string + } next: number } | { diff --git a/script/zen-limit-server.ts b/script/zen-limit-server.ts new file mode 100644 index 0000000000..3be1b5e111 --- /dev/null +++ b/script/zen-limit-server.ts @@ -0,0 +1,37 @@ +const retryAfterSeconds = 15 * 60 + +// const response = { +// type: "error", +// error: { +// type: "FreeUsageLimitError", +// message: "Free usage exceeded, subscribe to Go https://opencode.ai/go", +// }, +// metadata: {}, +// } + +const response = { + type: "error", + error: { + type: "GoUsageLimitError", + message: "Subscription quota exceeded. You can continue using free models.", + }, + metadata: { + workspace: "wrk_01K6XGM22R6FM8JVABE9XDQXGH", + limit: "5 hour", + resetAt: retryAfterSeconds, + }, +} + +Bun.serve({ + port: 4141, + fetch() { + return Response.json(response, { + status: 429, + headers: { + "retry-after": String(retryAfterSeconds), + }, + }) + }, +}) + +console.log("Zen limit repro server listening on http://localhost:4141")