Compare commits
2 commits
dev
...
fix/free-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
593fb51a07 | ||
|
|
767e17fbdc |
8 changed files with 147 additions and 15 deletions
|
|
@ -19,7 +19,11 @@ function goUpsellKeys(status: SessionStatus) {
|
||||||
if (status.type !== "retry" || !status.action) return
|
if (status.type !== "retry" || !status.action) return
|
||||||
const { action } = status
|
const { action } = status
|
||||||
if (!GO_UPSELL_PROVIDERS.has(action.provider)) 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 {
|
return {
|
||||||
lastSeenAt: GO_UPSELL_FREE_TIER_LAST_SEEN_AT,
|
lastSeenAt: GO_UPSELL_FREE_TIER_LAST_SEEN_AT,
|
||||||
dontShow: GO_UPSELL_FREE_TIER_DONT_SHOW,
|
dontShow: GO_UPSELL_FREE_TIER_DONT_SHOW,
|
||||||
|
|
@ -39,6 +43,7 @@ export function useUsageExceededDialogs() {
|
||||||
const { params } = useSessionLayout()
|
const { params } = useSessionLayout()
|
||||||
const { t, locale } = useI18n()
|
const { t, locale } = useI18n()
|
||||||
const isEnglish = () => locale() === "en"
|
const isEnglish = () => locale() === "en"
|
||||||
|
const useActionText = (reason: string) => isEnglish() || reason !== "free_tier_limit"
|
||||||
|
|
||||||
const [goUpsellState, setGoUpsellState] = persisted(
|
const [goUpsellState, setGoUpsellState] = persisted(
|
||||||
Persist.global("go-upsell"),
|
Persist.global("go-upsell"),
|
||||||
|
|
@ -65,20 +70,32 @@ export function useUsageExceededDialogs() {
|
||||||
if (seen && Date.now() - seen < GO_UPSELL_WINDOW) return
|
if (seen && Date.now() - seen < GO_UPSELL_WINDOW) return
|
||||||
if (goUpsellState[keys.dontShow]) 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(() => (
|
dialog.show(() => (
|
||||||
<DialogUsageExceeded
|
<DialogUsageExceeded
|
||||||
title={isEnglish() ? action.title : t("dialog.usageExceeded.freeTier.title")}
|
title={useActionText(action.reason) ? action.title : t("dialog.usageExceeded.freeTier.title")}
|
||||||
description={isEnglish() ? action.message : t("dialog.usageExceeded.freeTier.description")}
|
description={
|
||||||
actionLabel={isEnglish() ? action.label : t("dialog.usageExceeded.freeTier.actionLabel")}
|
useActionText(action.reason) ? action.message : t("dialog.usageExceeded.freeTier.description")
|
||||||
|
}
|
||||||
|
actionLabel={
|
||||||
|
useActionText(action.reason) ? action.label : t("dialog.usageExceeded.freeTier.actionLabel")
|
||||||
|
}
|
||||||
link={action.link}
|
link={action.link}
|
||||||
onClose={(dontShowAgain) => {
|
onClose={(dontShowAgain) => {
|
||||||
setGoUpsellState(keys.lastSeenAt, Date.now())
|
setGoUpsellState(keys.lastSeenAt, Date.now())
|
||||||
if (dontShowAgain) setGoUpsellState(keys.dontShow, 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) =>
|
void import("../../components/dialog-connect-provider").then((x) =>
|
||||||
dialog.show(() => <x.DialogConnectProvider provider="opencode-go" />),
|
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 />),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,18 @@ class LimitError extends Error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class RateLimitError extends LimitError {}
|
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 {}
|
export class BlackUsageLimitError extends LimitError {}
|
||||||
|
|
||||||
type LimitName = "5 hour" | "weekly" | "monthly"
|
type LimitName = "5 hour" | "weekly" | "monthly"
|
||||||
|
|
|
||||||
|
|
@ -120,8 +120,8 @@ export async function handler(
|
||||||
const rateLimiter = modelInfo.allowAnonymous
|
const rateLimiter = modelInfo.allowAnonymous
|
||||||
? createIpRateLimiter(modelInfo.id, modelInfo.rateLimit, ip, input.request)
|
? createIpRateLimiter(modelInfo.id, modelInfo.rateLimit, ip, input.request)
|
||||||
: createKeyRateLimiter(modelInfo.id, modelInfo.rateLimit, zenApiKey, input.request)
|
: createKeyRateLimiter(modelInfo.id, modelInfo.rateLimit, zenApiKey, input.request)
|
||||||
await rateLimiter?.check()
|
|
||||||
const authInfo = await authenticate(modelInfo, zenApiKey)
|
const authInfo = await authenticate(modelInfo, zenApiKey)
|
||||||
|
await rateLimiter?.check(freeUsageLimitMetadata(authInfo))
|
||||||
const stickyId = sessionId ? sessionId : (authInfo?.workspaceID ?? ip)
|
const stickyId = sessionId ? sessionId : (authInfo?.workspaceID ?? ip)
|
||||||
const stickyTracker = createStickyTracker(modelInfo.id, modelInfo.stickyProvider, stickyId)
|
const stickyTracker = createStickyTracker(modelInfo.id, modelInfo.stickyProvider, stickyId)
|
||||||
const stickyProvider = await stickyTracker?.get()
|
const stickyProvider = await stickyTracker?.get()
|
||||||
|
|
@ -424,6 +424,8 @@ export async function handler(
|
||||||
workspace: error.workspace,
|
workspace: error.workspace,
|
||||||
limitName: error.limitName,
|
limitName: error.limitName,
|
||||||
}
|
}
|
||||||
|
: error instanceof FreeUsageLimitError
|
||||||
|
? error.metadata
|
||||||
: {},
|
: {},
|
||||||
}),
|
}),
|
||||||
{ status: 429, headers },
|
{ status: 429, headers },
|
||||||
|
|
@ -881,6 +883,15 @@ export async function handler(
|
||||||
return "balance"
|
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) {
|
function validateModelSettings(billingSource: BillingSource, authInfo: AuthInfo) {
|
||||||
if (billingSource === "lite") return
|
if (billingSource === "lite") return
|
||||||
if (billingSource === "anonymous") return
|
if (billingSource === "anonymous") return
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { FreeUsageLimitError } from "./error"
|
import { FreeUsageLimitError, type FreeUsageLimitMetadata } from "./error"
|
||||||
import { logger } from "./logger"
|
import { logger } from "./logger"
|
||||||
import { buildRateLimitKey, getRedis } from "./redis"
|
import { buildRateLimitKey, getRedis } from "./redis"
|
||||||
import { i18n } from "~/i18n"
|
import { i18n } from "~/i18n"
|
||||||
|
|
@ -28,7 +28,7 @@ export function createRateLimiter(modelId: string, rateLimit: number | undefined
|
||||||
let isNew = false
|
let isNew = false
|
||||||
|
|
||||||
return {
|
return {
|
||||||
check: async () => {
|
check: async (metadata?: FreeUsageLimitMetadata) => {
|
||||||
const counts = await redis.mget<(string | number | null)[]>(isDefaultModel ? [lifetimeKey, dailyKey] : [dailyKey])
|
const counts = await redis.mget<(string | number | null)[]>(isDefaultModel ? [lifetimeKey, dailyKey] : [dailyKey])
|
||||||
const lifetimeCount = isDefaultModel ? Number(counts[0] ?? 0) : 0
|
const lifetimeCount = isDefaultModel ? Number(counts[0] ?? 0) : 0
|
||||||
const dailyCount = Number(counts[isDefaultModel ? 1 : 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
|
isNew = isDefaultModel && lifetimeCount < dailyLimit * 7
|
||||||
|
|
||||||
if ((isNew && dailyCount >= dailyLimit * 2) || (!isNew && dailyCount >= dailyLimit))
|
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 () => {
|
track: async () => {
|
||||||
const pipeline = redis.pipeline()
|
const pipeline = redis.pipeline()
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { RateLimitError } from "./error"
|
import { RateLimitError, type FreeUsageLimitMetadata } from "./error"
|
||||||
import { buildRateLimitKey, getRedis } from "./redis"
|
import { buildRateLimitKey, getRedis } from "./redis"
|
||||||
import { i18n } from "~/i18n"
|
import { i18n } from "~/i18n"
|
||||||
import { localeFromRequest } from "~/lib/language"
|
import { localeFromRequest } from "~/lib/language"
|
||||||
|
|
@ -22,7 +22,7 @@ export function createRateLimiter(
|
||||||
const key = buildRateLimitKey("key", zenApiKey, interval)
|
const key = buildRateLimitKey("key", zenApiKey, interval)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
check: async () => {
|
check: async (_metadata?: FreeUsageLimitMetadata) => {
|
||||||
const count = Number((await redis.mget<(string | number | null)[]>([key]))[0] ?? 0)
|
const count = Number((await redis.mget<(string | number | null)[]>([key]))[0] ?? 0)
|
||||||
|
|
||||||
if (count >= LIMIT) throw new RateLimitError(dict["zen.api.error.rateLimitExceeded"], 60)
|
if (count >= LIMIT) throw new RateLimitError(dict["zen.api.error.rateLimitExceeded"], 60)
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,11 @@ const GO_UPSELL_PROVIDERS = new Set(["opencode", "opencode-go"])
|
||||||
function goUpsellKeys(action: SessionRetry.Retryable["action"]) {
|
function goUpsellKeys(action: SessionRetry.Retryable["action"]) {
|
||||||
if (!action) return
|
if (!action) return
|
||||||
if (!GO_UPSELL_PROVIDERS.has(action.provider)) 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 {
|
return {
|
||||||
lastSeenAt: GO_UPSELL_FREE_TIER_LAST_SEEN_AT,
|
lastSeenAt: GO_UPSELL_FREE_TIER_LAST_SEEN_AT,
|
||||||
dontShow: GO_UPSELL_FREE_TIER_DONT_SHOW,
|
dontShow: GO_UPSELL_FREE_TIER_DONT_SHOW,
|
||||||
|
|
|
||||||
|
|
@ -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_MESSAGE = "Free usage exceeded, subscribe to Go"
|
||||||
export const GO_UPSELL_URL = "https://opencode.ai/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 = {
|
export type Retryable = {
|
||||||
message: string
|
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.
|
// 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")) {
|
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 {
|
return {
|
||||||
message: GO_UPSELL_MESSAGE,
|
message: GO_UPSELL_MESSAGE,
|
||||||
action: {
|
action: {
|
||||||
|
|
|
||||||
|
|
@ -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", () => {
|
test("maps Go subscription limits to workspace PAYG upsell", () => {
|
||||||
const error = Schema.decodeUnknownSync(SessionLegacy.APIError.Schema)(
|
const error = Schema.decodeUnknownSync(SessionLegacy.APIError.Schema)(
|
||||||
new SessionLegacy.APIError({
|
new SessionLegacy.APIError({
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue