diff --git a/packages/app/src/pages/session/usage-exceeded-dialogs.tsx b/packages/app/src/pages/session/usage-exceeded-dialogs.tsx index 5354410f48ad..1e7efc7070c3 100644 --- a/packages/app/src/pages/session/usage-exceeded-dialogs.tsx +++ b/packages/app/src/pages/session/usage-exceeded-dialogs.tsx @@ -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, @@ -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"), @@ -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(() => ( { 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(() => ), ) + } else if (action.reason === "free_tier_credit_available") { + void import("../../components/dialog-select-model").then((x) => + dialog.show(() => ), + ) } }} /> diff --git a/packages/console/app/src/routes/zen/util/error.ts b/packages/console/app/src/routes/zen/util/error.ts index d17741ff70d6..cd0cee628b15 100644 --- a/packages/console/app/src/routes/zen/util/error.ts +++ b/packages/console/app/src/routes/zen/util/error.ts @@ -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" diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index 4438688c22fe..0391ffa4286c 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -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() @@ -424,6 +424,8 @@ export async function handler( workspace: error.workspace, limitName: error.limitName, } + : error instanceof FreeUsageLimitError + ? error.metadata : {}, }), { status: 429, headers }, @@ -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 diff --git a/packages/console/app/src/routes/zen/util/ipRateLimiter.ts b/packages/console/app/src/routes/zen/util/ipRateLimiter.ts index 7461fa631355..73f47242b92b 100644 --- a/packages/console/app/src/routes/zen/util/ipRateLimiter.ts +++ b/packages/console/app/src/routes/zen/util/ipRateLimiter.ts @@ -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" @@ -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) @@ -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() diff --git a/packages/console/app/src/routes/zen/util/keyRateLimiter.ts b/packages/console/app/src/routes/zen/util/keyRateLimiter.ts index fdf5925299e5..4ae10f258fb5 100644 --- a/packages/console/app/src/routes/zen/util/keyRateLimiter.ts +++ b/packages/console/app/src/routes/zen/util/keyRateLimiter.ts @@ -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" @@ -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) 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 d8dbd689f1eb..de6b17f3b960 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -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, diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index bcfb54c47551..0f78292e8de3 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -9,7 +9,16 @@ export type Err = ReturnType 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 @@ -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: { diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 080db82bc057..4725bd445e88 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -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({