Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 23 additions & 6 deletions packages/app/src/pages/session/usage-exceeded-dialogs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@ function goUpsellKeys(status: SessionStatus) {
if (status.type !== "retry" || !status.action) return
const { action } = status
if (!GO_UPSELL_PROVIDERS.has(action.provider)) return
if (action.reason === "free_tier_limit") {
if (
action.reason === "free_tier_limit" ||
action.reason === "free_tier_go_subscriber" ||
action.reason === "free_tier_credit_available"
) {
return {
lastSeenAt: GO_UPSELL_FREE_TIER_LAST_SEEN_AT,
dontShow: GO_UPSELL_FREE_TIER_DONT_SHOW,
Expand All @@ -39,6 +43,7 @@ export function useUsageExceededDialogs() {
const { params } = useSessionLayout()
const { t, locale } = useI18n()
const isEnglish = () => locale() === "en"
const useActionText = (reason: string) => isEnglish() || reason !== "free_tier_limit"

const [goUpsellState, setGoUpsellState] = persisted(
Persist.global("go-upsell"),
Expand All @@ -65,20 +70,32 @@ export function useUsageExceededDialogs() {
if (seen && Date.now() - seen < GO_UPSELL_WINDOW) return
if (goUpsellState[keys.dontShow]) return

if (action.reason === "free_tier_limit") {
if (
action.reason === "free_tier_limit" ||
action.reason === "free_tier_go_subscriber" ||
action.reason === "free_tier_credit_available"
) {
dialog.show(() => (
<DialogUsageExceeded
title={isEnglish() ? action.title : t("dialog.usageExceeded.freeTier.title")}
description={isEnglish() ? action.message : t("dialog.usageExceeded.freeTier.description")}
actionLabel={isEnglish() ? action.label : t("dialog.usageExceeded.freeTier.actionLabel")}
title={useActionText(action.reason) ? action.title : t("dialog.usageExceeded.freeTier.title")}
description={
useActionText(action.reason) ? action.message : t("dialog.usageExceeded.freeTier.description")
}
actionLabel={
useActionText(action.reason) ? action.label : t("dialog.usageExceeded.freeTier.actionLabel")
}
link={action.link}
onClose={(dontShowAgain) => {
setGoUpsellState(keys.lastSeenAt, Date.now())
if (dontShowAgain) setGoUpsellState(keys.dontShow, Date.now())
else {
else if (action.reason === "free_tier_limit" || action.reason === "free_tier_go_subscriber") {
void import("../../components/dialog-connect-provider").then((x) =>
dialog.show(() => <x.DialogConnectProvider provider="opencode-go" />),
)
} else if (action.reason === "free_tier_credit_available") {
void import("../../components/dialog-select-model").then((x) =>
dialog.show(() => <x.DialogSelectModel />),
)
}
}}
/>
Expand Down
13 changes: 12 additions & 1 deletion packages/console/app/src/routes/zen/util/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,18 @@ class LimitError extends Error {
}
}
export class RateLimitError extends LimitError {}
export class FreeUsageLimitError extends LimitError {}
export type FreeUsageLimitMetadata = {
workspace?: string
subscribedToGo?: boolean
hasCredits?: boolean
}
export class FreeUsageLimitError extends LimitError {
metadata: FreeUsageLimitMetadata
constructor(message: string, retryAfter?: number, metadata: FreeUsageLimitMetadata = {}) {
super(message, retryAfter)
this.metadata = metadata
}
}
export class BlackUsageLimitError extends LimitError {}

type LimitName = "5 hour" | "weekly" | "monthly"
Expand Down
13 changes: 12 additions & 1 deletion packages/console/app/src/routes/zen/util/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,8 @@ export async function handler(
const rateLimiter = modelInfo.allowAnonymous
? createIpRateLimiter(modelInfo.id, modelInfo.rateLimit, ip, input.request)
: createKeyRateLimiter(modelInfo.id, modelInfo.rateLimit, zenApiKey, input.request)
await rateLimiter?.check()
const authInfo = await authenticate(modelInfo, zenApiKey)
await rateLimiter?.check(freeUsageLimitMetadata(authInfo))
const stickyId = sessionId ? sessionId : (authInfo?.workspaceID ?? ip)
const stickyTracker = createStickyTracker(modelInfo.id, modelInfo.stickyProvider, stickyId)
const stickyProvider = await stickyTracker?.get()
Expand Down Expand Up @@ -424,6 +424,8 @@ export async function handler(
workspace: error.workspace,
limitName: error.limitName,
}
: error instanceof FreeUsageLimitError
? error.metadata
: {},
}),
{ status: 429, headers },
Expand Down Expand Up @@ -881,6 +883,15 @@ export async function handler(
return "balance"
}

function freeUsageLimitMetadata(authInfo: AuthInfo) {
if (!authInfo) return {}
return {
workspace: authInfo.workspaceID,
subscribedToGo: !!(authInfo.billing.lite || authInfo.lite),
hasCredits: authInfo.billing.balance > 0,
}
}

function validateModelSettings(billingSource: BillingSource, authInfo: AuthInfo) {
if (billingSource === "lite") return
if (billingSource === "anonymous") return
Expand Down
6 changes: 3 additions & 3 deletions packages/console/app/src/routes/zen/util/ipRateLimiter.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { FreeUsageLimitError } from "./error"
import { FreeUsageLimitError, type FreeUsageLimitMetadata } from "./error"
import { logger } from "./logger"
import { buildRateLimitKey, getRedis } from "./redis"
import { i18n } from "~/i18n"
Expand Down Expand Up @@ -28,7 +28,7 @@ export function createRateLimiter(modelId: string, rateLimit: number | undefined
let isNew = false

return {
check: async () => {
check: async (metadata?: FreeUsageLimitMetadata) => {
const counts = await redis.mget<(string | number | null)[]>(isDefaultModel ? [lifetimeKey, dailyKey] : [dailyKey])
const lifetimeCount = isDefaultModel ? Number(counts[0] ?? 0) : 0
const dailyCount = Number(counts[isDefaultModel ? 1 : 0] ?? 0)
Expand All @@ -37,7 +37,7 @@ export function createRateLimiter(modelId: string, rateLimit: number | undefined
isNew = isDefaultModel && lifetimeCount < dailyLimit * 7

if ((isNew && dailyCount >= dailyLimit * 2) || (!isNew && dailyCount >= dailyLimit))
throw new FreeUsageLimitError(dict["zen.api.error.rateLimitExceeded"], retryAfter)
throw new FreeUsageLimitError(dict["zen.api.error.rateLimitExceeded"], retryAfter, metadata)
},
track: async () => {
const pipeline = redis.pipeline()
Expand Down
4 changes: 2 additions & 2 deletions packages/console/app/src/routes/zen/util/keyRateLimiter.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { RateLimitError } from "./error"
import { RateLimitError, type FreeUsageLimitMetadata } from "./error"
import { buildRateLimitKey, getRedis } from "./redis"
import { i18n } from "~/i18n"
import { localeFromRequest } from "~/lib/language"
Expand All @@ -22,7 +22,7 @@ export function createRateLimiter(
const key = buildRateLimitKey("key", zenApiKey, interval)

return {
check: async () => {
check: async (_metadata?: FreeUsageLimitMetadata) => {
const count = Number((await redis.mget<(string | number | null)[]>([key]))[0] ?? 0)

if (count >= LIMIT) throw new RateLimitError(dict["zen.api.error.rateLimitExceeded"], 60)
Expand Down
6 changes: 5 additions & 1 deletion packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,11 @@ const GO_UPSELL_PROVIDERS = new Set(["opencode", "opencode-go"])
function goUpsellKeys(action: SessionRetry.Retryable["action"]) {
if (!action) return
if (!GO_UPSELL_PROVIDERS.has(action.provider)) return
if (action.reason === "free_tier_limit") {
if (
action.reason === "free_tier_limit" ||
action.reason === "free_tier_go_subscriber" ||
action.reason === "free_tier_credit_available"
) {
return {
lastSeenAt: GO_UPSELL_FREE_TIER_LAST_SEEN_AT,
dontShow: GO_UPSELL_FREE_TIER_DONT_SHOW,
Expand Down
37 changes: 36 additions & 1 deletion packages/opencode/src/session/retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,16 @@ export type Err = ReturnType<NamedError["toObject"]>

export const GO_UPSELL_MESSAGE = "Free usage exceeded, subscribe to Go"
export const GO_UPSELL_URL = "https://opencode.ai/go"
export type RetryReason = "free_tier_limit" | "account_rate_limit" | (string & {})
const GO_PROVIDER_MESSAGE =
"Free usage exceeded. You're already subscribed to OpenCode Go; switch to the OpenCode Go provider to continue with higher usage limits."
const PAID_MODEL_MESSAGE =
"Free usage exceeded. You have credits available; switch to a paid model to continue using your balance."
export type RetryReason =
| "free_tier_limit"
| "free_tier_go_subscriber"
| "free_tier_credit_available"
| "account_rate_limit"
| (string & {})

export type Retryable = {
message: string
Expand Down Expand Up @@ -74,6 +83,32 @@ export function retryable(error: Err, provider: string) {
// 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")) {
const body = parseJSON(error.data.responseBody)
const metadata = body?.metadata
if (metadata?.subscribedToGo === true) {
return {
message: GO_PROVIDER_MESSAGE,
action: {
reason: "free_tier_go_subscriber",
provider,
title: "Free limit reached",
message: GO_PROVIDER_MESSAGE,
label: "switch provider",
},
}
}
if (metadata?.hasCredits === true) {
return {
message: PAID_MODEL_MESSAGE,
action: {
reason: "free_tier_credit_available",
provider,
title: "Free limit reached",
message: PAID_MODEL_MESSAGE,
label: "switch model",
},
}
}
return {
message: GO_UPSELL_MESSAGE,
action: {
Expand Down
54 changes: 54 additions & 0 deletions packages/opencode/test/session/retry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,60 @@ describe("session.retry.retryable", () => {
})
})

test("maps free limits for Go subscribers to Go provider action", () => {
const error = Schema.decodeUnknownSync(SessionLegacy.APIError.Schema)(
new SessionLegacy.APIError({
message: "Free usage exceeded",
isRetryable: true,
statusCode: 429,
responseBody: JSON.stringify({
type: "error",
error: { type: "FreeUsageLimitError", message: "Free usage exceeded" },
metadata: { subscribedToGo: true, hasCredits: true },
}),
}).toObject(),
)

expect(SessionRetry.retryable(error, "opencode")).toEqual({
message:
"Free usage exceeded. You're already subscribed to OpenCode Go; switch to the OpenCode Go provider to continue with higher usage limits.",
action: {
reason: "free_tier_go_subscriber",
provider: "opencode",
title: "Free limit reached",
message:
"Free usage exceeded. You're already subscribed to OpenCode Go; switch to the OpenCode Go provider to continue with higher usage limits.",
label: "switch provider",
},
})
})

test("maps free limits with credits to paid model action", () => {
const error = Schema.decodeUnknownSync(SessionLegacy.APIError.Schema)(
new SessionLegacy.APIError({
message: "Free usage exceeded",
isRetryable: true,
statusCode: 429,
responseBody: JSON.stringify({
type: "error",
error: { type: "FreeUsageLimitError", message: "Free usage exceeded" },
metadata: { hasCredits: true },
}),
}).toObject(),
)

expect(SessionRetry.retryable(error, "opencode")).toEqual({
message: "Free usage exceeded. You have credits available; switch to a paid model to continue using your balance.",
action: {
reason: "free_tier_credit_available",
provider: "opencode",
title: "Free limit reached",
message: "Free usage exceeded. You have credits available; switch to a paid model to continue using your balance.",
label: "switch model",
},
})
})

test("maps Go subscription limits to workspace PAYG upsell", () => {
const error = Schema.decodeUnknownSync(SessionLegacy.APIError.Schema)(
new SessionLegacy.APIError({
Expand Down
Loading