Aiden Cline 4 месяцев назад
Родитель
Сommit
f5d0371efe

+ 52 - 42
packages/opencode/src/cli/cmd/tui/component/dialog-go-upsell.tsx → 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<typeof useDialog>) {
-  open(GO_URL).catch(() => {})
+function runAction(props: DialogRetryActionProps, dialog: ReturnType<typeof useDialog>) {
+  if (props.link) open(props.link).catch(() => {})
   props.onClose?.()
   dialog.clear()
 }
 
-function dismiss(props: DialogGoUpsellProps, dialog: ReturnType<typeof useDialog>) {
+function dismiss(props: DialogRetryActionProps, dialog: ReturnType<typeof useDialog>) {
   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<BgPulseMask[]>([])
+  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 (
     <box ref={(item: BoxRenderable) => (content = item)}>
-      <box position="absolute" top={-PAD_TOP_OUTER} left={0} right={0} bottom={0} zIndex={0}>
-        <BgPulse centerX={center()?.x} centerY={center()?.y} masks={masks()} />
-      </box>
-      <box paddingLeft={PAD_X} paddingRight={PAD_X} paddingBottom={1} gap={1}>
+      {showGoTreatment() ? (
+        <box position="absolute" top={-PAD_TOP_OUTER} left={0} right={0} bottom={0} zIndex={0}>
+          <BgPulse centerX={center()?.x} centerY={center()?.y} masks={masks()} />
+        </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">
           <text attributes={TextAttributes.BOLD} fg={theme.text}>
-            Free limit reached
+            {props.title}
           </text>
           <text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
             esc
           </text>
         </box>
         <box ref={(item: BoxRenderable) => (descBox = item)} gap={0}>
-          <box flexDirection="row">
-            <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>
+          <text fg={theme.textMuted}>{props.message}</text>
         </box>
-        <box alignItems="center" gap={1} paddingBottom={1}>
-          <box ref={(item: BoxRenderable) => (logoBox = item)}>
-            <GoLogo />
-          </box>
-          <Link href={GO_URL} fg={theme.primary} />
+        <box gap={1} paddingBottom={1}>
+          {showGoTreatment() ? (
+            <box ref={(item: BoxRenderable) => (logoBox = item)} alignItems="center">
+              <GoLogo />
+            </box>
+          ) : null}
+          {props.link ? (
+            <box width="100%" flexDirection="row" justifyContent="center">
+              <Link href={props.link} fg={theme.primary} wrapMode="none" />
+            </box>
+          ) : null}
         </box>
         <box ref={(item: BoxRenderable) => (buttonsBox = item)} flexDirection="row" justifyContent="space-between">
           <box
@@ -143,15 +150,15 @@ export function DialogGoUpsell(props: DialogGoUpsellProps) {
           <box
             paddingLeft={2}
             paddingRight={2}
-            backgroundColor={selected() === "subscribe" ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
-            onMouseOver={() => setSelected("subscribe")}
-            onMouseUp={() => subscribe(props, dialog)}
+            backgroundColor={selected() === "action" ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
+            onMouseOver={() => setSelected("action")}
+            onMouseUp={() => runAction(props, dialog)}
           >
             <text
-              fg={selected() === "subscribe" ? fg : theme.text}
-              attributes={selected() === "subscribe" ? TextAttributes.BOLD : undefined}
+              fg={selected() === "action" ? fg : theme.text}
+              attributes={selected() === "action" ? TextAttributes.BOLD : undefined}
             >
-              subscribe
+              {props.label}
             </text>
           </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) => {
     dialog.replace(
-      () => <DialogGoUpsell onClose={(dontShow) => resolve(dontShow ?? false)} />,
+      () => <DialogRetryAction {...props} onClose={(dontShow) => resolve(dontShow ?? false)} />,
       () => resolve(false),
     )
   })

+ 3 - 3
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())
     })

+ 4 - 0
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 (
     <text
       fg={props.fg}
+      width={props.width}
+      wrapMode={props.wrapMode}
       onMouseUp={() => {
         open(props.href).catch(() => {})
       }}

+ 1 - 0
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,
                   })
                 },

+ 92 - 25
packages/opencode/src/session/retry.ts

@@ -5,9 +5,19 @@ import { iife } from "@/util/iife"
 
 export type Err = ReturnType<NamedError["toObject"]>
 
-// 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<void>
+  set: (input: { attempt: number; message: string; action?: Retryable["action"]; next: number }) => Effect.Effect<void>
 }) {
   return Schedule.fromStepWithMetadata(
     Effect.succeed((meta: Schedule.InputMetadata<unknown>) => {
       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]
       })
     }),

+ 8 - 0
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({

+ 69 - 11
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." })
   })
 })

+ 13 - 2
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)
   })

+ 6 - 0
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
     }
   | {

+ 37 - 0
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")