tui: go plan payg msg (#26248)
This commit is contained in:
@@ -717,6 +717,7 @@ export const layer: Layer.Layer<
|
||||
type: "retry",
|
||||
attempt: info.attempt,
|
||||
message: info.message,
|
||||
action: info.action,
|
||||
next: info.next,
|
||||
})
|
||||
},
|
||||
|
||||
@@ -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]
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user