tui: go plan payg msg (#26248)
This commit is contained in:
parent
22e64cac67
commit
f5d0371efe
10 changed files with 285 additions and 83 deletions
|
|
@ -8,32 +8,36 @@ import { GoLogo } from "./logo"
|
||||||
import { BgPulse, type BgPulseMask } from "./bg-pulse"
|
import { BgPulse, type BgPulseMask } from "./bg-pulse"
|
||||||
import { useBindings } from "../keymap"
|
import { useBindings } from "../keymap"
|
||||||
|
|
||||||
const GO_URL = "https://opencode.ai/go"
|
|
||||||
const PAD_X = 3
|
const PAD_X = 3
|
||||||
const PAD_TOP_OUTER = 1
|
const PAD_TOP_OUTER = 1
|
||||||
|
|
||||||
export type DialogGoUpsellProps = {
|
export type DialogRetryActionProps = {
|
||||||
|
title: string
|
||||||
|
message: string
|
||||||
|
label: string
|
||||||
|
link?: string
|
||||||
onClose?: (dontShowAgain?: boolean) => void
|
onClose?: (dontShowAgain?: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function subscribe(props: DialogGoUpsellProps, dialog: ReturnType<typeof useDialog>) {
|
function runAction(props: DialogRetryActionProps, dialog: ReturnType<typeof useDialog>) {
|
||||||
open(GO_URL).catch(() => {})
|
if (props.link) open(props.link).catch(() => {})
|
||||||
props.onClose?.()
|
props.onClose?.()
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
function dismiss(props: DialogGoUpsellProps, dialog: ReturnType<typeof useDialog>) {
|
function dismiss(props: DialogRetryActionProps, dialog: ReturnType<typeof useDialog>) {
|
||||||
props.onClose?.(true)
|
props.onClose?.(true)
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DialogGoUpsell(props: DialogGoUpsellProps) {
|
export function DialogRetryAction(props: DialogRetryActionProps) {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const fg = selectedForeground(theme)
|
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 [center, setCenter] = createSignal<{ x: number; y: number } | undefined>()
|
||||||
const [masks, setMasks] = createSignal<BgPulseMask[]>([])
|
const [masks, setMasks] = createSignal<BgPulseMask[]>([])
|
||||||
|
const showGoTreatment = () => props.link === "https://opencode.ai/go"
|
||||||
let content: BoxRenderable | undefined
|
let content: BoxRenderable | undefined
|
||||||
let logoBox: BoxRenderable | undefined
|
let logoBox: BoxRenderable | undefined
|
||||||
let headingBox: BoxRenderable | undefined
|
let headingBox: BoxRenderable | undefined
|
||||||
|
|
@ -41,11 +45,13 @@ export function DialogGoUpsell(props: DialogGoUpsellProps) {
|
||||||
let buttonsBox: BoxRenderable | undefined
|
let buttonsBox: BoxRenderable | undefined
|
||||||
|
|
||||||
const sync = () => {
|
const sync = () => {
|
||||||
if (!content || !logoBox) return
|
if (!content) return
|
||||||
setCenter({
|
if (logoBox) {
|
||||||
x: logoBox.x - content.x + logoBox.width / 2,
|
setCenter({
|
||||||
y: logoBox.y - content.y + logoBox.height / 2 + PAD_TOP_OUTER,
|
x: logoBox.x - content.x + logoBox.width / 2,
|
||||||
})
|
y: logoBox.y - content.y + logoBox.height / 2 + PAD_TOP_OUTER,
|
||||||
|
})
|
||||||
|
}
|
||||||
const next: BgPulseMask[] = []
|
const next: BgPulseMask[] = []
|
||||||
const baseY = PAD_TOP_OUTER
|
const baseY = PAD_TOP_OUTER
|
||||||
for (const b of [headingBox, descBox, buttonsBox]) {
|
for (const b of [headingBox, descBox, buttonsBox]) {
|
||||||
|
|
@ -75,20 +81,20 @@ export function DialogGoUpsell(props: DialogGoUpsellProps) {
|
||||||
bindings: [
|
bindings: [
|
||||||
{
|
{
|
||||||
key: "left",
|
key: "left",
|
||||||
cmd: () => setSelected((value) => (value === "subscribe" ? "dismiss" : "subscribe")),
|
cmd: () => setSelected((value) => (value === "action" ? "dismiss" : "action")),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "right",
|
key: "right",
|
||||||
cmd: () => setSelected((value) => (value === "subscribe" ? "dismiss" : "subscribe")),
|
cmd: () => setSelected((value) => (value === "action" ? "dismiss" : "action")),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "tab",
|
key: "tab",
|
||||||
cmd: () => setSelected((value) => (value === "subscribe" ? "dismiss" : "subscribe")),
|
cmd: () => setSelected((value) => (value === "action" ? "dismiss" : "action")),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "return",
|
key: "return",
|
||||||
cmd: () => {
|
cmd: () => {
|
||||||
if (selected() === "subscribe") subscribe(props, dialog)
|
if (selected() === "action") runAction(props, dialog)
|
||||||
else dismiss(props, dialog)
|
else dismiss(props, dialog)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -97,33 +103,34 @@ export function DialogGoUpsell(props: DialogGoUpsellProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box ref={(item: BoxRenderable) => (content = item)}>
|
<box ref={(item: BoxRenderable) => (content = item)}>
|
||||||
<box position="absolute" top={-PAD_TOP_OUTER} left={0} right={0} bottom={0} zIndex={0}>
|
{showGoTreatment() ? (
|
||||||
<BgPulse centerX={center()?.x} centerY={center()?.y} masks={masks()} />
|
<box position="absolute" top={-PAD_TOP_OUTER} left={0} right={0} bottom={0} zIndex={0}>
|
||||||
</box>
|
<BgPulse centerX={center()?.x} centerY={center()?.y} masks={masks()} />
|
||||||
<box paddingLeft={PAD_X} paddingRight={PAD_X} paddingBottom={1} gap={1}>
|
</box>
|
||||||
|
) : null}
|
||||||
|
<box paddingLeft={PAD_X} paddingRight={PAD_X} paddingBottom={1} gap={1} zIndex={1}>
|
||||||
<box ref={(item: BoxRenderable) => (headingBox = item)} flexDirection="row" justifyContent="space-between">
|
<box ref={(item: BoxRenderable) => (headingBox = item)} flexDirection="row" justifyContent="space-between">
|
||||||
<text attributes={TextAttributes.BOLD} fg={theme.text}>
|
<text attributes={TextAttributes.BOLD} fg={theme.text}>
|
||||||
Free limit reached
|
{props.title}
|
||||||
</text>
|
</text>
|
||||||
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
|
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
|
||||||
esc
|
esc
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
<box ref={(item: BoxRenderable) => (descBox = item)} gap={0}>
|
<box ref={(item: BoxRenderable) => (descBox = item)} gap={0}>
|
||||||
<box flexDirection="row">
|
<text fg={theme.textMuted}>{props.message}</text>
|
||||||
<text fg={theme.textMuted}>Subscribe to </text>
|
|
||||||
<text attributes={TextAttributes.BOLD} fg={theme.textMuted}>
|
|
||||||
OpenCode Go
|
|
||||||
</text>
|
|
||||||
<text fg={theme.textMuted}> for reliable access to the</text>
|
|
||||||
</box>
|
|
||||||
<text fg={theme.textMuted}>best open-source models, starting at $5/month.</text>
|
|
||||||
</box>
|
</box>
|
||||||
<box alignItems="center" gap={1} paddingBottom={1}>
|
<box gap={1} paddingBottom={1}>
|
||||||
<box ref={(item: BoxRenderable) => (logoBox = item)}>
|
{showGoTreatment() ? (
|
||||||
<GoLogo />
|
<box ref={(item: BoxRenderable) => (logoBox = item)} alignItems="center">
|
||||||
</box>
|
<GoLogo />
|
||||||
<Link href={GO_URL} fg={theme.primary} />
|
</box>
|
||||||
|
) : null}
|
||||||
|
{props.link ? (
|
||||||
|
<box width="100%" flexDirection="row" justifyContent="center">
|
||||||
|
<Link href={props.link} fg={theme.primary} wrapMode="none" />
|
||||||
|
</box>
|
||||||
|
) : null}
|
||||||
</box>
|
</box>
|
||||||
<box ref={(item: BoxRenderable) => (buttonsBox = item)} flexDirection="row" justifyContent="space-between">
|
<box ref={(item: BoxRenderable) => (buttonsBox = item)} flexDirection="row" justifyContent="space-between">
|
||||||
<box
|
<box
|
||||||
|
|
@ -143,15 +150,15 @@ export function DialogGoUpsell(props: DialogGoUpsellProps) {
|
||||||
<box
|
<box
|
||||||
paddingLeft={2}
|
paddingLeft={2}
|
||||||
paddingRight={2}
|
paddingRight={2}
|
||||||
backgroundColor={selected() === "subscribe" ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
|
backgroundColor={selected() === "action" ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
|
||||||
onMouseOver={() => setSelected("subscribe")}
|
onMouseOver={() => setSelected("action")}
|
||||||
onMouseUp={() => subscribe(props, dialog)}
|
onMouseUp={() => runAction(props, dialog)}
|
||||||
>
|
>
|
||||||
<text
|
<text
|
||||||
fg={selected() === "subscribe" ? fg : theme.text}
|
fg={selected() === "action" ? fg : theme.text}
|
||||||
attributes={selected() === "subscribe" ? TextAttributes.BOLD : undefined}
|
attributes={selected() === "action" ? TextAttributes.BOLD : undefined}
|
||||||
>
|
>
|
||||||
subscribe
|
{props.label}
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
|
|
@ -160,10 +167,13 @@ export function DialogGoUpsell(props: DialogGoUpsellProps) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
DialogGoUpsell.show = (dialog: DialogContext) => {
|
DialogRetryAction.show = (
|
||||||
|
dialog: DialogContext,
|
||||||
|
props: Pick<DialogRetryActionProps, "title" | "message" | "label" | "link">,
|
||||||
|
) => {
|
||||||
return new Promise<boolean>((resolve) => {
|
return new Promise<boolean>((resolve) => {
|
||||||
dialog.replace(
|
dialog.replace(
|
||||||
() => <DialogGoUpsell onClose={(dontShow) => resolve(dontShow ?? false)} />,
|
() => <DialogRetryAction {...props} onClose={(dontShow) => resolve(dontShow ?? false)} />,
|
||||||
() => resolve(false),
|
() => resolve(false),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
@ -85,7 +85,7 @@ import { UI } from "@/cli/ui.ts"
|
||||||
import { useTuiConfig } from "../../context/tui-config"
|
import { useTuiConfig } from "../../context/tui-config"
|
||||||
import { getScrollAcceleration } from "../../util/scroll"
|
import { getScrollAcceleration } from "../../util/scroll"
|
||||||
import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
|
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 { SessionRetry } from "@/session/retry"
|
||||||
import { getRevertDiffFiles } from "../../util/revert-diff"
|
import { getRevertDiffFiles } from "../../util/revert-diff"
|
||||||
import { useCommandPalette } from "../../context/command-palette"
|
import { useCommandPalette } from "../../context/command-palette"
|
||||||
|
|
@ -260,7 +260,7 @@ export function Session() {
|
||||||
event.on("session.status", (evt) => {
|
event.on("session.status", (evt) => {
|
||||||
if (evt.properties.sessionID !== route.sessionID) return
|
if (evt.properties.sessionID !== route.sessionID) return
|
||||||
if (evt.properties.status.type !== "retry") 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
|
if (dialog.stack.length > 0) return
|
||||||
|
|
||||||
const seen = kv.get(GO_UPSELL_LAST_SEEN_AT)
|
const seen = kv.get(GO_UPSELL_LAST_SEEN_AT)
|
||||||
|
|
@ -268,7 +268,7 @@ export function Session() {
|
||||||
|
|
||||||
if (kv.get(GO_UPSELL_DONT_SHOW)) return
|
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)
|
if (dontShowAgain) kv.set(GO_UPSELL_DONT_SHOW, true)
|
||||||
kv.set(GO_UPSELL_LAST_SEEN_AT, Date.now())
|
kv.set(GO_UPSELL_LAST_SEEN_AT, Date.now())
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ export interface LinkProps {
|
||||||
href: string
|
href: string
|
||||||
children?: JSX.Element | string
|
children?: JSX.Element | string
|
||||||
fg?: RGBA
|
fg?: RGBA
|
||||||
|
width?: number | "auto" | `${number}%`
|
||||||
|
wrapMode?: "word" | "none"
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -18,6 +20,8 @@ export function Link(props: LinkProps) {
|
||||||
return (
|
return (
|
||||||
<text
|
<text
|
||||||
fg={props.fg}
|
fg={props.fg}
|
||||||
|
width={props.width}
|
||||||
|
wrapMode={props.wrapMode}
|
||||||
onMouseUp={() => {
|
onMouseUp={() => {
|
||||||
open(props.href).catch(() => {})
|
open(props.href).catch(() => {})
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -717,6 +717,7 @@ export const layer: Layer.Layer<
|
||||||
type: "retry",
|
type: "retry",
|
||||||
attempt: info.attempt,
|
attempt: info.attempt,
|
||||||
message: info.message,
|
message: info.message,
|
||||||
|
action: info.action,
|
||||||
next: info.next,
|
next: info.next,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,19 @@ import { iife } from "@/util/iife"
|
||||||
|
|
||||||
export type Err = ReturnType<NamedError["toObject"]>
|
export type Err = ReturnType<NamedError["toObject"]>
|
||||||
|
|
||||||
// This exported message is shared with the TUI upsell detector. Matching on a
|
export const GO_UPSELL_MESSAGE = "Free usage exceeded, subscribe to Go"
|
||||||
// literal error string kind of sucks, but it is the simplest for now.
|
export const PAYG_UPSELL_MESSAGE = "Go usage exceeded, enable PAYG"
|
||||||
export const GO_UPSELL_MESSAGE = "Free usage exceeded, subscribe to Go https://opencode.ai/go"
|
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_INITIAL_DELAY = 2000
|
||||||
export const RETRY_BACKOFF_FACTOR = 2
|
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,
|
// 5xx errors are transient server failures and should always be retried,
|
||||||
// even when the provider SDK doesn't explicitly mark them as retryable.
|
// 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.isRetryable && !(status !== undefined && status >= 500)) return undefined
|
||||||
if (error.data.responseBody?.includes("FreeUsageLimitError")) return GO_UPSELL_MESSAGE
|
if (error.data.responseBody?.includes("FreeUsageLimitError")) {
|
||||||
return error.data.message.includes("Overloaded") ? "Provider is overloaded" : error.data.message
|
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
|
// 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("rate limit") ||
|
||||||
lower.includes("too many requests")
|
lower.includes("too many requests")
|
||||||
) {
|
) {
|
||||||
return msg
|
return { message: msg }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const json = iife(() => {
|
const json = parseJSON(error.data?.message)
|
||||||
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
|
|
||||||
}
|
|
||||||
})
|
|
||||||
if (!json || typeof json !== "object") return undefined
|
if (!json || typeof json !== "object") return undefined
|
||||||
const code = typeof json.code === "string" ? json.code : ""
|
const code = typeof json.code === "string" ? json.code : ""
|
||||||
|
|
||||||
if (json.type === "error" && json.error?.type === "too_many_requests") {
|
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")) {
|
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")) {
|
if (json.type === "error" && typeof json.error?.code === "string" && json.error.code.includes("rate_limit")) {
|
||||||
return "Rate Limited"
|
return { message: "Rate Limited" }
|
||||||
}
|
}
|
||||||
return undefined
|
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: {
|
export function policy(opts: {
|
||||||
parse: (error: unknown) => Err
|
parse: (error: unknown) => Err
|
||||||
set: (input: { attempt: number; message: string; next: number }) => Effect.Effect<void>
|
set: (input: { attempt: number; message: string; action?: Retryable["action"]; next: number }) => Effect.Effect<void>
|
||||||
}) {
|
}) {
|
||||||
return Schedule.fromStepWithMetadata(
|
return Schedule.fromStepWithMetadata(
|
||||||
Effect.succeed((meta: Schedule.InputMetadata<unknown>) => {
|
Effect.succeed((meta: Schedule.InputMetadata<unknown>) => {
|
||||||
const error = opts.parse(meta.input)
|
const error = opts.parse(meta.input)
|
||||||
const message = retryable(error)
|
const retry = retryable(error)
|
||||||
if (!message) return Cause.done(meta.attempt)
|
if (!retry) return Cause.done(meta.attempt)
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const wait = delay(meta.attempt, MessageV2.APIError.isInstance(error) ? error : undefined)
|
const wait = delay(meta.attempt, MessageV2.APIError.isInstance(error) ? error : undefined)
|
||||||
const now = yield* Clock.currentTimeMillis
|
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]
|
return [meta.attempt, Duration.millis(wait)] as [number, Duration.Duration]
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,14 @@ export const Info = Schema.Union([
|
||||||
type: Schema.Literal("retry"),
|
type: Schema.Literal("retry"),
|
||||||
attempt: NonNegativeInt,
|
attempt: NonNegativeInt,
|
||||||
message: Schema.String,
|
message: Schema.String,
|
||||||
|
action: Schema.optional(
|
||||||
|
Schema.Struct({
|
||||||
|
title: Schema.String,
|
||||||
|
message: Schema.String,
|
||||||
|
label: Schema.String,
|
||||||
|
link: Schema.optional(Schema.String),
|
||||||
|
}),
|
||||||
|
),
|
||||||
next: NonNegativeInt,
|
next: NonNegativeInt,
|
||||||
}),
|
}),
|
||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
|
|
|
||||||
|
|
@ -118,12 +118,12 @@ describe("session.retry.delay", () => {
|
||||||
describe("session.retry.retryable", () => {
|
describe("session.retry.retryable", () => {
|
||||||
test("maps too_many_requests json messages", () => {
|
test("maps too_many_requests json messages", () => {
|
||||||
const error = wrap(JSON.stringify({ type: "error", error: { type: "too_many_requests" } }))
|
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", () => {
|
test("maps overloaded provider codes", () => {
|
||||||
const error = wrap(JSON.stringify({ code: "resource_exhausted" }))
|
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", () => {
|
test("does not retry unknown json messages", () => {
|
||||||
|
|
@ -146,19 +146,19 @@ describe("session.retry.retryable", () => {
|
||||||
const msg =
|
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."
|
"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)
|
const error = wrap(msg)
|
||||||
expect(SessionRetry.retryable(error)).toBe(msg)
|
expect(SessionRetry.retryable(error)).toEqual({ message: msg })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("retries plain text rate limit errors", () => {
|
test("retries plain text rate limit errors", () => {
|
||||||
const msg = "Rate limit exceeded, please try again later"
|
const msg = "Rate limit exceeded, please try again later"
|
||||||
const error = wrap(msg)
|
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", () => {
|
test("retries too many requests in plain text", () => {
|
||||||
const msg = "Too many requests, please slow down"
|
const msg = "Too many requests, please slow down"
|
||||||
const error = wrap(msg)
|
const error = wrap(msg)
|
||||||
expect(SessionRetry.retryable(error)).toBe(msg)
|
expect(SessionRetry.retryable(error)).toEqual({ message: msg })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("does not retry context overflow errors", () => {
|
test("does not retry context overflow errors", () => {
|
||||||
|
|
@ -180,7 +180,7 @@ describe("session.retry.retryable", () => {
|
||||||
}).toObject(),
|
}).toObject(),
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(SessionRetry.retryable(error)).toBe("Internal server error")
|
expect(SessionRetry.retryable(error)).toEqual({ message: "Internal server error" })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("retries 502 bad gateway errors", () => {
|
test("retries 502 bad gateway errors", () => {
|
||||||
|
|
@ -192,7 +192,7 @@ describe("session.retry.retryable", () => {
|
||||||
}).toObject(),
|
}).toObject(),
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(SessionRetry.retryable(error)).toBe("Bad gateway")
|
expect(SessionRetry.retryable(error)).toEqual({ message: "Bad gateway" })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("retries 503 service unavailable errors", () => {
|
test("retries 503 service unavailable errors", () => {
|
||||||
|
|
@ -204,7 +204,7 @@ describe("session.retry.retryable", () => {
|
||||||
}).toObject(),
|
}).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", () => {
|
test("does not retry 4xx errors when isRetryable is false", () => {
|
||||||
|
|
@ -230,7 +230,65 @@ describe("session.retry.retryable", () => {
|
||||||
|
|
||||||
const retryable = SessionRetry.retryable(error)
|
const retryable = SessionRetry.retryable(error)
|
||||||
expect(retryable).toBeDefined()
|
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)
|
const retryable = SessionRetry.retryable(error)
|
||||||
expect(retryable).toBeDefined()
|
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", () => {
|
test("marks OpenAI 404 status codes as retryable", () => {
|
||||||
|
|
@ -321,6 +379,6 @@ describe("session.message-v2.fromError", () => {
|
||||||
expect(MessageV2.APIError.isInstance(result)).toBe(true)
|
expect(MessageV2.APIError.isInstance(result)).toBe(true)
|
||||||
if (!MessageV2.APIError.isInstance(result)) throw new Error("expected APIError")
|
if (!MessageV2.APIError.isInstance(result)) throw new Error("expected APIError")
|
||||||
expect(result.data.isRetryable).toBe(true)
|
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." })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -230,8 +230,19 @@ describe("SessionStatus.Info", () => {
|
||||||
expect(SessionStatus.Info.zod.parse({ type: "idle" })).toEqual({ type: "idle" })
|
expect(SessionStatus.Info.zod.parse({ type: "idle" })).toEqual({ type: "idle" })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("retry carries attempt/message/next", () => {
|
test("retry carries attempt/message/action/next", () => {
|
||||||
const input = { type: "retry" as const, attempt: 1, message: "transient", next: 500 }
|
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(decode(input)).toEqual(input)
|
||||||
expect(SessionStatus.Info.zod.parse(input)).toEqual(input)
|
expect(SessionStatus.Info.zod.parse(input)).toEqual(input)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -266,6 +266,12 @@ export type SessionStatus =
|
||||||
type: "retry"
|
type: "retry"
|
||||||
attempt: number
|
attempt: number
|
||||||
message: string
|
message: string
|
||||||
|
action?: {
|
||||||
|
title: string
|
||||||
|
message: string
|
||||||
|
label: string
|
||||||
|
link?: string
|
||||||
|
}
|
||||||
next: number
|
next: number
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
|
|
|
||||||
37
script/zen-limit-server.ts
Normal file
37
script/zen-limit-server.ts
Normal file
|
|
@ -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")
|
||||||
Loading…
Add table
Add a link
Reference in a new issue