This commit is contained in:
Frank 2026-03-03 00:25:03 -05:00
parent 9f150b0776
commit 6aa4928e9e
23 changed files with 422 additions and 313 deletions

View File

@ -118,7 +118,6 @@ const ZEN_LITE_PRICE = new sst.Linkable("ZEN_LITE_PRICE", {
price: zenLitePrice.id,
},
})
const ZEN_LITE_LIMITS = new sst.Secret("ZEN_LITE_LIMITS")
const zenBlackProduct = new stripe.Product("ZenBlack", {
name: "OpenCode Black",
@ -142,7 +141,6 @@ const ZEN_BLACK_PRICE = new sst.Linkable("ZEN_BLACK_PRICE", {
plan20: zenBlackPrice20.id,
},
})
const ZEN_BLACK_LIMITS = new sst.Secret("ZEN_BLACK_LIMITS")
const ZEN_MODELS = [
new sst.Secret("ZEN_MODELS1"),
@ -215,9 +213,8 @@ new sst.cloudflare.x.SolidStart("Console", {
AWS_SES_ACCESS_KEY_ID,
AWS_SES_SECRET_ACCESS_KEY,
ZEN_BLACK_PRICE,
ZEN_BLACK_LIMITS,
ZEN_LITE_PRICE,
ZEN_LITE_LIMITS,
new sst.Secret("ZEN_LIMITS"),
new sst.Secret("ZEN_SESSION_SECRET"),
...ZEN_MODELS,
...($dev

View File

@ -97,9 +97,9 @@ export async function handler(
const zenData = ZenData.list(opts.modelList)
const modelInfo = validateModel(zenData, model)
const dataDumper = createDataDumper(sessionId, requestId, projectId)
const trialLimiter = createTrialLimiter(modelInfo.trial, ip, ocClient)
const isTrial = await trialLimiter?.isTrial()
const rateLimiter = createRateLimiter(modelInfo.rateLimit, ip, input.request)
const trialLimiter = createTrialLimiter(modelInfo.trialProvider, ip)
const trialProvider = await trialLimiter?.check()
const rateLimiter = createRateLimiter(modelInfo.allowAnonymous, ip, input.request)
await rateLimiter?.check()
const stickyTracker = createStickyTracker(modelInfo.stickyProvider, sessionId)
const stickyProvider = await stickyTracker?.get()
@ -114,7 +114,7 @@ export async function handler(
authInfo,
modelInfo,
sessionId,
isTrial ?? false,
trialProvider,
retry,
stickyProvider,
)
@ -144,9 +144,6 @@ export async function handler(
Object.entries(providerInfo.headerMappings ?? {}).forEach(([k, v]) => {
headers.set(k, headers.get(v)!)
})
Object.entries(providerInfo.headers ?? {}).forEach(([k, v]) => {
headers.set(k, v)
})
headers.delete("host")
headers.delete("content-length")
headers.delete("x-opencode-request")
@ -295,18 +292,13 @@ export async function handler(
part = part.trim()
usageParser.parse(part)
if (providerInfo.responseModifier) {
for (const [k, v] of Object.entries(providerInfo.responseModifier)) {
part = part.replace(k, v)
}
c.enqueue(encoder.encode(part + "\n\n"))
} else if (providerInfo.format !== opts.format) {
if (providerInfo.format !== opts.format) {
part = streamConverter(part)
c.enqueue(encoder.encode(part + "\n\n"))
}
}
if (!providerInfo.responseModifier && providerInfo.format === opts.format) {
if (providerInfo.format === opts.format) {
c.enqueue(value)
}
@ -398,7 +390,7 @@ export async function handler(
authInfo: AuthInfo,
modelInfo: ModelInfo,
sessionId: string,
isTrial: boolean,
trialProvider: string | undefined,
retry: RetryOptions,
stickyProvider: string | undefined,
) {
@ -407,8 +399,8 @@ export async function handler(
return modelInfo.providers.find((provider) => provider.id === modelInfo.byokProvider)
}
if (isTrial) {
return modelInfo.providers.find((provider) => provider.id === modelInfo.trial!.provider)
if (trialProvider) {
return modelInfo.providers.find((provider) => provider.id === trialProvider)
}
if (stickyProvider) {

View File

@ -2,29 +2,28 @@ import { Database, eq, and, sql, inArray } from "@opencode-ai/console-core/drizz
import { IpRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js"
import { FreeUsageLimitError } from "./error"
import { logger } from "./logger"
import { ZenData } from "@opencode-ai/console-core/model.js"
import { i18n } from "~/i18n"
import { localeFromRequest } from "~/lib/language"
import { Subscription } from "@opencode-ai/console-core/subscription.js"
export function createRateLimiter(limit: ZenData.RateLimit | undefined, rawIp: string, request: Request) {
if (!limit) return
export function createRateLimiter(allowAnonymous: boolean | undefined, rawIp: string, request: Request) {
if (!allowAnonymous) return
const dict = i18n(localeFromRequest(request))
const limitValue = limit.checkHeader && !request.headers.get(limit.checkHeader) ? limit.fallbackValue! : limit.value
const limits = Subscription.getFreeLimits()
const limitValue =
limits.checkHeader && !request.headers.get(limits.checkHeader) ? limits.fallbackValue : limits.dailyRequests
const ip = !rawIp.length ? "unknown" : rawIp
const now = Date.now()
const intervals =
limit.period === "day"
? [buildYYYYMMDD(now)]
: [buildYYYYMMDDHH(now), buildYYYYMMDDHH(now - 3_600_000), buildYYYYMMDDHH(now - 7_200_000)]
const interval = buildYYYYMMDD(now)
return {
track: async () => {
await Database.use((tx) =>
tx
.insert(IpRateLimitTable)
.values({ ip, interval: intervals[0], count: 1 })
.values({ ip, interval, count: 1 })
.onDuplicateKeyUpdate({ set: { count: sql`${IpRateLimitTable.count} + 1` } }),
)
},
@ -33,15 +32,12 @@ export function createRateLimiter(limit: ZenData.RateLimit | undefined, rawIp: s
tx
.select({ interval: IpRateLimitTable.interval, count: IpRateLimitTable.count })
.from(IpRateLimitTable)
.where(and(eq(IpRateLimitTable.ip, ip), inArray(IpRateLimitTable.interval, intervals))),
.where(and(eq(IpRateLimitTable.ip, ip), inArray(IpRateLimitTable.interval, [interval]))),
)
const total = rows.reduce((sum, r) => sum + r.count, 0)
logger.debug(`rate limit total: ${total}`)
if (total >= limitValue)
throw new FreeUsageLimitError(
dict["zen.api.error.rateLimitExceeded"],
limit.period === "day" ? getRetryAfterDay(now) : getRetryAfterHour(rows, intervals, limitValue, now),
)
throw new FreeUsageLimitError(dict["zen.api.error.rateLimitExceeded"], getRetryAfterDay(now))
},
}
}
@ -50,37 +46,9 @@ export function getRetryAfterDay(now: number) {
return Math.ceil((86_400_000 - (now % 86_400_000)) / 1000)
}
export function getRetryAfterHour(
rows: { interval: string; count: number }[],
intervals: string[],
limit: number,
now: number,
) {
const counts = new Map(rows.map((r) => [r.interval, r.count]))
// intervals are ordered newest to oldest: [current, -1h, -2h]
// simulate dropping oldest intervals one at a time
let running = intervals.reduce((sum, i) => sum + (counts.get(i) ?? 0), 0)
for (let i = intervals.length - 1; i >= 0; i--) {
running -= counts.get(intervals[i]) ?? 0
if (running < limit) {
// interval at index i rolls out of the window (intervals.length - i) hours from the current hour start
const hours = intervals.length - i
return Math.ceil((hours * 3_600_000 - (now % 3_600_000)) / 1000)
}
}
return Math.ceil((3_600_000 - (now % 3_600_000)) / 1000)
}
function buildYYYYMMDD(timestamp: number) {
return new Date(timestamp)
.toISOString()
.replace(/[^0-9]/g, "")
.substring(0, 8)
}
function buildYYYYMMDDHH(timestamp: number) {
return new Date(timestamp)
.toISOString()
.replace(/[^0-9]/g, "")
.substring(0, 10)
}

View File

@ -1,21 +1,18 @@
import { Database, eq, sql } from "@opencode-ai/console-core/drizzle/index.js"
import { IpTable } from "@opencode-ai/console-core/schema/ip.sql.js"
import { UsageInfo } from "./provider/provider"
import { ZenData } from "@opencode-ai/console-core/model.js"
import { Subscription } from "@opencode-ai/console-core/subscription.js"
export function createTrialLimiter(trial: ZenData.Trial | undefined, ip: string, client: string) {
if (!trial) return
export function createTrialLimiter(trialProvider: string | undefined, ip: string) {
if (!trialProvider) return
if (!ip) return
const limit =
trial.limits.find((limit) => limit.client === client)?.limit ??
trial.limits.find((limit) => limit.client === undefined)?.limit
if (!limit) return
const limit = Subscription.getFreeLimits().promoTokens
let _isTrial: boolean
return {
isTrial: async () => {
check: async () => {
const data = await Database.use((tx) =>
tx
.select({
@ -27,7 +24,7 @@ export function createTrialLimiter(trial: ZenData.Trial | undefined, ip: string,
)
_isTrial = (data?.usage ?? 0) < limit
return _isTrial
return _isTrial ? trialProvider : undefined
},
track: async (usageInfo: UsageInfo) => {
if (!_isTrial) return

View File

@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { getRetryAfterDay, getRetryAfterHour } from "../src/routes/zen/util/rateLimiter"
import { getRetryAfterDay } from "../src/routes/zen/util/rateLimiter"
describe("getRetryAfterDay", () => {
test("returns full day at midnight UTC", () => {
@ -17,76 +17,3 @@ describe("getRetryAfterDay", () => {
expect(getRetryAfterDay(almost)).toBe(1)
})
})
describe("getRetryAfterHour", () => {
// 14:30:00 UTC — 30 minutes into the current hour
const now = Date.UTC(2026, 0, 15, 14, 30, 0, 0)
const intervals = ["2026011514", "2026011513", "2026011512"]
test("waits 3 hours when all usage is in current hour", () => {
const rows = [{ interval: "2026011514", count: 10 }]
// only current hour has usage — it won't leave the window for 3 hours from hour start
// 3 * 3600 - 1800 = 9000s
expect(getRetryAfterHour(rows, intervals, 10, now)).toBe(9000)
})
test("waits 1 hour when dropping oldest interval is sufficient", () => {
const rows = [
{ interval: "2026011514", count: 2 },
{ interval: "2026011512", count: 10 },
]
// total=12, drop oldest (-2h, count=10) -> 2 < 10
// hours = 3 - 2 = 1 -> 1 * 3600 - 1800 = 1800s
expect(getRetryAfterHour(rows, intervals, 10, now)).toBe(1800)
})
test("waits 2 hours when usage spans oldest two intervals", () => {
const rows = [
{ interval: "2026011513", count: 8 },
{ interval: "2026011512", count: 5 },
]
// total=13, drop -2h (5) -> 8, 8 >= 8, drop -1h (8) -> 0 < 8
// hours = 3 - 1 = 2 -> 2 * 3600 - 1800 = 5400s
expect(getRetryAfterHour(rows, intervals, 8, now)).toBe(5400)
})
test("waits 1 hour when oldest interval alone pushes over limit", () => {
const rows = [
{ interval: "2026011514", count: 1 },
{ interval: "2026011513", count: 1 },
{ interval: "2026011512", count: 10 },
]
// total=12, drop -2h (10) -> 2 < 10
// hours = 3 - 2 = 1 -> 1800s
expect(getRetryAfterHour(rows, intervals, 10, now)).toBe(1800)
})
test("waits 2 hours when middle interval keeps total over limit", () => {
const rows = [
{ interval: "2026011514", count: 4 },
{ interval: "2026011513", count: 4 },
{ interval: "2026011512", count: 4 },
]
// total=12, drop -2h (4) -> 8, 8 >= 5, drop -1h (4) -> 4 < 5
// hours = 3 - 1 = 2 -> 5400s
expect(getRetryAfterHour(rows, intervals, 5, now)).toBe(5400)
})
test("rounds up to nearest second", () => {
const offset = Date.UTC(2026, 0, 15, 14, 30, 0, 500)
const rows = [
{ interval: "2026011514", count: 2 },
{ interval: "2026011512", count: 10 },
]
// hours=1 -> 3_600_000 - 1_800_500 = 1_799_500ms -> ceil(1799.5) = 1800
expect(getRetryAfterHour(rows, intervals, 10, offset)).toBe(1800)
})
test("fallback returns time until next hour when rows are empty", () => {
// edge case: rows empty but function called (shouldn't happen in practice)
// loop drops all zeros, running stays 0 which is < any positive limit on first iteration
const rows: { interval: string; count: number }[] = []
// drop -2h (0) -> 0 < 1 -> hours = 3 - 2 = 1 -> 1800s
expect(getRetryAfterHour(rows, intervals, 1, now)).toBe(1800)
})
})

View File

@ -34,12 +34,9 @@
"promote-models-to-prod": "script/promote-models.ts production",
"pull-models-from-dev": "script/pull-models.ts dev",
"pull-models-from-prod": "script/pull-models.ts production",
"update-black": "script/update-black.ts",
"promote-black-to-dev": "script/promote-black.ts dev",
"promote-black-to-prod": "script/promote-black.ts production",
"update-lite": "script/update-lite.ts",
"promote-lite-to-dev": "script/promote-lite.ts dev",
"promote-lite-to-prod": "script/promote-lite.ts production",
"update-limits": "script/update-limits.ts",
"promote-limits-to-dev": "script/promote-limits.ts dev",
"promote-limits-to-prod": "script/promote-limits.ts production",
"typecheck": "tsgo --noEmit"
},
"devDependencies": {

View File

@ -0,0 +1,312 @@
import { Database, and, eq, inArray, isNotNull, sql } from "../src/drizzle/index.js"
import { BillingTable, BlackPlans, SubscriptionTable, UsageTable } from "../src/schema/billing.sql.js"
if (process.argv.length < 3) {
console.error("Usage: bun black-stats.ts <plan>")
process.exit(1)
}
const plan = process.argv[2] as (typeof BlackPlans)[number]
if (!BlackPlans.includes(plan)) {
console.error("Usage: bun black-stats.ts <plan>")
process.exit(1)
}
const cutoff = new Date(Date.UTC(2026, 1, 0, 23, 59, 59, 999))
// get workspaces
const workspaces = await Database.use((tx) =>
tx
.select({ workspaceID: BillingTable.workspaceID })
.from(BillingTable)
.where(
and(isNotNull(BillingTable.subscriptionID), sql`JSON_UNQUOTE(JSON_EXTRACT(subscription, '$.plan')) = ${plan}`),
),
)
if (workspaces.length === 0) throw new Error(`No active Black ${plan} subscriptions found`)
const week = sql<number>`YEARWEEK(${UsageTable.timeCreated}, 3)`
const workspaceIDs = workspaces.map((row) => row.workspaceID)
// Get subscription spend
const spend = await Database.use((tx) =>
tx
.select({
workspaceID: UsageTable.workspaceID,
week,
amount: sql<number>`COALESCE(SUM(${UsageTable.cost}), 0)`,
})
.from(UsageTable)
.where(
and(inArray(UsageTable.workspaceID, workspaceIDs), sql`JSON_UNQUOTE(JSON_EXTRACT(enrichment, '$.plan')) = 'sub'`),
)
.groupBy(UsageTable.workspaceID, week),
)
// Get pay per use spend
const ppu = await Database.use((tx) =>
tx
.select({
workspaceID: UsageTable.workspaceID,
week,
amount: sql<number>`COALESCE(SUM(${UsageTable.cost}), 0)`,
})
.from(UsageTable)
.where(
and(
inArray(UsageTable.workspaceID, workspaceIDs),
sql`(${UsageTable.enrichment} IS NULL OR JSON_UNQUOTE(JSON_EXTRACT(enrichment, '$.plan')) != 'sub')`,
),
)
.groupBy(UsageTable.workspaceID, week),
)
const models = await Database.use((tx) =>
tx
.select({
workspaceID: UsageTable.workspaceID,
model: UsageTable.model,
amount: sql<number>`COALESCE(SUM(${UsageTable.cost}), 0)`,
})
.from(UsageTable)
.where(
and(inArray(UsageTable.workspaceID, workspaceIDs), sql`JSON_UNQUOTE(JSON_EXTRACT(enrichment, '$.plan')) = 'sub'`),
)
.groupBy(UsageTable.workspaceID, UsageTable.model),
)
const tokens = await Database.use((tx) =>
tx
.select({
workspaceID: UsageTable.workspaceID,
week,
input: sql<number>`COALESCE(SUM(${UsageTable.inputTokens}), 0)`,
cacheRead: sql<number>`COALESCE(SUM(${UsageTable.cacheReadTokens}), 0)`,
output: sql<number>`COALESCE(SUM(${UsageTable.outputTokens}), 0) + COALESCE(SUM(${UsageTable.reasoningTokens}), 0)`,
})
.from(UsageTable)
.where(
and(inArray(UsageTable.workspaceID, workspaceIDs), sql`JSON_UNQUOTE(JSON_EXTRACT(enrichment, '$.plan')) = 'sub'`),
)
.groupBy(UsageTable.workspaceID, week),
)
const allWeeks = [...spend, ...ppu].map((row) => row.week)
const weeks = [...new Set(allWeeks)].sort((a, b) => a - b)
const spendMap = new Map<string, Map<number, number>>()
const totals = new Map<string, number>()
const ppuMap = new Map<string, Map<number, number>>()
const ppuTotals = new Map<string, number>()
const modelMap = new Map<string, { model: string; amount: number }[]>()
const tokenMap = new Map<string, Map<number, { input: number; cacheRead: number; output: number }>>()
for (const row of spend) {
const workspace = spendMap.get(row.workspaceID) ?? new Map<number, number>()
const total = totals.get(row.workspaceID) ?? 0
const amount = toNumber(row.amount)
workspace.set(row.week, amount)
totals.set(row.workspaceID, total + amount)
spendMap.set(row.workspaceID, workspace)
}
for (const row of ppu) {
const workspace = ppuMap.get(row.workspaceID) ?? new Map<number, number>()
const total = ppuTotals.get(row.workspaceID) ?? 0
const amount = toNumber(row.amount)
workspace.set(row.week, amount)
ppuTotals.set(row.workspaceID, total + amount)
ppuMap.set(row.workspaceID, workspace)
}
for (const row of models) {
const current = modelMap.get(row.workspaceID) ?? []
current.push({ model: row.model, amount: toNumber(row.amount) })
modelMap.set(row.workspaceID, current)
}
for (const row of tokens) {
const workspace = tokenMap.get(row.workspaceID) ?? new Map()
workspace.set(row.week, {
input: toNumber(row.input),
cacheRead: toNumber(row.cacheRead),
output: toNumber(row.output),
})
tokenMap.set(row.workspaceID, workspace)
}
const users = await Database.use((tx) =>
tx
.select({
workspaceID: SubscriptionTable.workspaceID,
subscribed: SubscriptionTable.timeCreated,
subscription: BillingTable.subscription,
})
.from(SubscriptionTable)
.innerJoin(BillingTable, eq(SubscriptionTable.workspaceID, BillingTable.workspaceID))
.where(
and(inArray(SubscriptionTable.workspaceID, workspaceIDs), sql`${SubscriptionTable.timeCreated} <= ${cutoff}`),
),
)
const counts = new Map<string, number>()
for (const user of users) {
const current = counts.get(user.workspaceID) ?? 0
counts.set(user.workspaceID, current + 1)
}
const rows = users
.map((user) => {
const workspace = spendMap.get(user.workspaceID) ?? new Map<number, number>()
const ppuWorkspace = ppuMap.get(user.workspaceID) ?? new Map<number, number>()
const count = counts.get(user.workspaceID) ?? 1
const amount = (totals.get(user.workspaceID) ?? 0) / count
const ppuAmount = (ppuTotals.get(user.workspaceID) ?? 0) / count
const monthStart = user.subscribed ? startOfMonth(user.subscribed) : null
const modelRows = (modelMap.get(user.workspaceID) ?? []).sort((a, b) => b.amount - a.amount).slice(0, 3)
const modelTotal = totals.get(user.workspaceID) ?? 0
const modelCells = modelRows.map((row) => ({
model: row.model,
percent: modelTotal > 0 ? `${((row.amount / modelTotal) * 100).toFixed(1)}%` : "0.0%",
}))
const modelData = [0, 1, 2].map((index) => modelCells[index] ?? { model: "-", percent: "-" })
const weekly = Object.fromEntries(
weeks.map((item) => {
const value = (workspace.get(item) ?? 0) / count
const beforeMonth = monthStart ? isoWeekStart(item) < monthStart : false
return [formatWeek(item), beforeMonth ? "-" : formatMicroCents(value)]
}),
)
const ppuWeekly = Object.fromEntries(
weeks.map((item) => {
const value = (ppuWorkspace.get(item) ?? 0) / count
const beforeMonth = monthStart ? isoWeekStart(item) < monthStart : false
return [formatWeek(item), beforeMonth ? "-" : formatMicroCents(value)]
}),
)
const tokenWorkspace = tokenMap.get(user.workspaceID) ?? new Map()
const weeklyTokens = Object.fromEntries(
weeks.map((item) => {
const t = tokenWorkspace.get(item) ?? { input: 0, cacheRead: 0, output: 0 }
const beforeMonth = monthStart ? isoWeekStart(item) < monthStart : false
return [
formatWeek(item),
beforeMonth
? { input: "-", cacheRead: "-", output: "-" }
: {
input: Math.round(t.input / count),
cacheRead: Math.round(t.cacheRead / count),
output: Math.round(t.output / count),
},
]
}),
)
return {
workspaceID: user.workspaceID,
useBalance: user.subscription?.useBalance ?? false,
subscribed: formatDate(user.subscribed),
subscribedAt: user.subscribed?.getTime() ?? 0,
amount,
ppuAmount,
models: modelData,
weekly,
ppuWeekly,
weeklyTokens,
}
})
.sort((a, b) => a.subscribedAt - b.subscribedAt)
console.log(`Black ${plan} subscribers: ${rows.length}`)
const header = [
"workspaceID",
"subscribed",
"useCredit",
"subTotal",
"ppuTotal",
"model1",
"model1%",
"model2",
"model2%",
"model3",
"model3%",
...weeks.flatMap((item) => [
formatWeek(item) + " sub",
formatWeek(item) + " ppu",
formatWeek(item) + " input",
formatWeek(item) + " cache",
formatWeek(item) + " output",
]),
]
const lines = [header.map(csvCell).join(",")]
for (const row of rows) {
const model1 = row.models[0]
const model2 = row.models[1]
const model3 = row.models[2]
const cells = [
row.workspaceID,
row.subscribed ?? "",
row.useBalance ? "yes" : "no",
formatMicroCents(row.amount),
formatMicroCents(row.ppuAmount),
model1.model,
model1.percent,
model2.model,
model2.percent,
model3.model,
model3.percent,
...weeks.flatMap((item) => {
const t = row.weeklyTokens[formatWeek(item)] ?? { input: "-", cacheRead: "-", output: "-" }
return [
row.weekly[formatWeek(item)] ?? "",
row.ppuWeekly[formatWeek(item)] ?? "",
String(t.input),
String(t.cacheRead),
String(t.output),
]
}),
]
lines.push(cells.map(csvCell).join(","))
}
const output = `${lines.join("\n")}\n`
const file = Bun.file(`black-stats-${plan}.csv`)
await file.write(output)
console.log(`Wrote ${lines.length - 1} rows to ${file.name}`)
const total = rows.reduce((sum, row) => sum + row.amount, 0)
const average = rows.length === 0 ? 0 : total / rows.length
console.log(`Average spending per user: ${formatMicroCents(average)}`)
function formatMicroCents(value: number) {
return `$${(value / 100000000).toFixed(2)}`
}
function formatDate(value: Date | null | undefined) {
if (!value) return null
return value.toISOString().split("T")[0]
}
function formatWeek(value: number) {
return formatDate(isoWeekStart(value)) ?? ""
}
function startOfMonth(value: Date) {
return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), 1))
}
function isoWeekStart(value: number) {
const year = Math.floor(value / 100)
const weekNumber = value % 100
const jan4 = new Date(Date.UTC(year, 0, 4))
const day = jan4.getUTCDay() || 7
const weekStart = new Date(Date.UTC(year, 0, 4 - (day - 1)))
weekStart.setUTCDate(weekStart.getUTCDate() + (weekNumber - 1) * 7)
return weekStart
}
function toNumber(value: unknown) {
if (typeof value === "number") return value
if (typeof value === "bigint") return Number(value)
if (typeof value === "string") return Number(value)
return 0
}
function csvCell(value: string | number) {
const text = String(value)
if (!/[",\n]/.test(text)) return text
return `"${text.replace(/"/g, '""')}"`
}

View File

@ -1,22 +0,0 @@
#!/usr/bin/env bun
import { $ } from "bun"
import path from "path"
import { BlackData } from "../src/black"
const stage = process.argv[2]
if (!stage) throw new Error("Stage is required")
const root = path.resolve(process.cwd(), "..", "..", "..")
// read the secret
const ret = await $`bun sst secret list`.cwd(root).text()
const lines = ret.split("\n")
const value = lines.find((line) => line.startsWith("ZEN_BLACK_LIMITS"))?.split("=")[1]
if (!value) throw new Error("ZEN_BLACK_LIMITS not found")
// validate value
BlackData.validate(JSON.parse(value))
// update the secret
await $`bun sst secret set ZEN_BLACK_LIMITS ${value} --stage ${stage}`

View File

@ -2,7 +2,7 @@
import { $ } from "bun"
import path from "path"
import { LiteData } from "../src/lite"
import { Subscription } from "../src/subscription"
const stage = process.argv[2]
if (!stage) throw new Error("Stage is required")
@ -12,11 +12,11 @@ const root = path.resolve(process.cwd(), "..", "..", "..")
// read the secret
const ret = await $`bun sst secret list`.cwd(root).text()
const lines = ret.split("\n")
const value = lines.find((line) => line.startsWith("ZEN_LITE_LIMITS"))?.split("=")[1]
if (!value) throw new Error("ZEN_LITE_LIMITS not found")
const value = lines.find((line) => line.startsWith("ZEN_LIMITS"))?.split("=")[1]
if (!value) throw new Error("ZEN_LIMITS not found")
// validate value
LiteData.validate(JSON.parse(value))
Subscription.validate(JSON.parse(value))
// update the secret
await $`bun sst secret set ZEN_LITE_LIMITS ${value} --stage ${stage}`
await $`bun sst secret set ZEN_LIMITS ${value} --stage ${stage}`

View File

@ -1,28 +0,0 @@
#!/usr/bin/env bun
import { $ } from "bun"
import path from "path"
import os from "os"
import { BlackData } from "../src/black"
const root = path.resolve(process.cwd(), "..", "..", "..")
const secrets = await $`bun sst secret list`.cwd(root).text()
// read value
const lines = secrets.split("\n")
const oldValue = lines.find((line) => line.startsWith("ZEN_BLACK_LIMITS"))?.split("=")[1] ?? "{}"
if (!oldValue) throw new Error("ZEN_BLACK_LIMITS not found")
// store the prettified json to a temp file
const filename = `black-${Date.now()}.json`
const tempFile = Bun.file(path.join(os.tmpdir(), filename))
await tempFile.write(JSON.stringify(JSON.parse(oldValue), null, 2))
console.log("tempFile", tempFile.name)
// open temp file in vim and read the file on close
await $`vim ${tempFile.name}`
const newValue = JSON.stringify(JSON.parse(await tempFile.text()))
BlackData.validate(JSON.parse(newValue))
// update the secret
await $`bun sst secret set ZEN_BLACK_LIMITS ${newValue}`

View File

@ -3,18 +3,18 @@
import { $ } from "bun"
import path from "path"
import os from "os"
import { LiteData } from "../src/lite"
import { Subscription } from "../src/subscription"
const root = path.resolve(process.cwd(), "..", "..", "..")
const secrets = await $`bun sst secret list`.cwd(root).text()
// read value
const lines = secrets.split("\n")
const oldValue = lines.find((line) => line.startsWith("ZEN_LITE_LIMITS"))?.split("=")[1] ?? "{}"
if (!oldValue) throw new Error("ZEN_LITE_LIMITS not found")
const oldValue = lines.find((line) => line.startsWith("ZEN_LIMITS"))?.split("=")[1] ?? "{}"
if (!oldValue) throw new Error("ZEN_LIMITS not found")
// store the prettified json to a temp file
const filename = `lite-${Date.now()}.json`
const filename = `limits-${Date.now()}.json`
const tempFile = Bun.file(path.join(os.tmpdir(), filename))
await tempFile.write(JSON.stringify(JSON.parse(oldValue), null, 2))
console.log("tempFile", tempFile.name)
@ -22,7 +22,7 @@ console.log("tempFile", tempFile.name)
// open temp file in vim and read the file on close
await $`vim ${tempFile.name}`
const newValue = JSON.stringify(JSON.parse(await tempFile.text()))
LiteData.validate(JSON.parse(newValue))
Subscription.validate(JSON.parse(newValue))
// update the secret
await $`bun sst secret set ZEN_LITE_LIMITS ${newValue}`
await $`bun sst secret set ZEN_LIMITS ${newValue}`

View File

@ -2,37 +2,15 @@ import { z } from "zod"
import { fn } from "./util/fn"
import { Resource } from "@opencode-ai/console-resource"
import { BlackPlans } from "./schema/billing.sql"
import { Subscription } from "./subscription"
export namespace BlackData {
const Schema = z.object({
"200": z.object({
fixedLimit: z.number().int(),
rollingLimit: z.number().int(),
rollingWindow: z.number().int(),
}),
"100": z.object({
fixedLimit: z.number().int(),
rollingLimit: z.number().int(),
rollingWindow: z.number().int(),
}),
"20": z.object({
fixedLimit: z.number().int(),
rollingLimit: z.number().int(),
rollingWindow: z.number().int(),
}),
})
export const validate = fn(Schema, (input) => {
return input
})
export const getLimits = fn(
z.object({
plan: z.enum(BlackPlans),
}),
({ plan }) => {
const json = JSON.parse(Resource.ZEN_BLACK_LIMITS.value)
return Schema.parse(json)[plan]
return Subscription.getLimits()["black"][plan]
},
)

View File

@ -1,22 +1,11 @@
import { z } from "zod"
import { fn } from "./util/fn"
import { Resource } from "@opencode-ai/console-resource"
import { Subscription } from "./subscription"
export namespace LiteData {
const Schema = z.object({
rollingLimit: z.number().int(),
rollingWindow: z.number().int(),
weeklyLimit: z.number().int(),
monthlyLimit: z.number().int(),
})
export const validate = fn(Schema, (input) => {
return input
})
export const getLimits = fn(z.void(), () => {
const json = JSON.parse(Resource.ZEN_LITE_LIMITS.value)
return Schema.parse(json)
return Subscription.getLimits()["lite"]
})
export const productID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.product)

View File

@ -9,24 +9,7 @@ import { Resource } from "@opencode-ai/console-resource"
export namespace ZenData {
const FormatSchema = z.enum(["anthropic", "google", "openai", "oa-compat"])
const TrialSchema = z.object({
provider: z.string(),
limits: z.array(
z.object({
limit: z.number(),
client: z.enum(["cli", "desktop"]).optional(),
}),
),
})
const RateLimitSchema = z.object({
period: z.enum(["day", "rolling"]),
value: z.number().int(),
checkHeader: z.string().optional(),
fallbackValue: z.number().int().optional(),
})
export type Format = z.infer<typeof FormatSchema>
export type Trial = z.infer<typeof TrialSchema>
export type RateLimit = z.infer<typeof RateLimitSchema>
const ModelCostSchema = z.object({
input: z.number(),
@ -43,8 +26,7 @@ export namespace ZenData {
allowAnonymous: z.boolean().optional(),
byokProvider: z.enum(["openai", "anthropic", "google"]).optional(),
stickyProvider: z.enum(["strict", "prefer"]).optional(),
trial: TrialSchema.optional(),
rateLimit: RateLimitSchema.optional(),
trialProvider: z.string().optional(),
fallbackProvider: z.string().optional(),
providers: z.array(
z.object({
@ -63,19 +45,12 @@ export namespace ZenData {
format: FormatSchema.optional(),
headerMappings: z.record(z.string(), z.string()).optional(),
payloadModifier: z.record(z.string(), z.any()).optional(),
family: z.string().optional(),
})
const ProviderFamilySchema = z.object({
headers: z.record(z.string(), z.string()).optional(),
responseModifier: z.record(z.string(), z.string()).optional(),
})
const ModelsSchema = z.object({
models: z.record(z.string(), z.union([ModelSchema, z.array(ModelSchema.extend({ formatFilter: FormatSchema }))])),
liteModels: z.record(z.string(), ModelSchema),
providers: z.record(z.string(), ProviderSchema),
providerFamilies: z.record(z.string(), ProviderFamilySchema),
})
export const validate = fn(ModelsSchema, (input) => {
@ -115,15 +90,10 @@ export namespace ZenData {
Resource.ZEN_MODELS29.value +
Resource.ZEN_MODELS30.value,
)
const { models, liteModels, providers, providerFamilies } = ModelsSchema.parse(json)
const { models, liteModels, providers } = ModelsSchema.parse(json)
return {
models: modelList === "lite" ? liteModels : models,
providers: Object.fromEntries(
Object.entries(providers).map(([id, provider]) => [
id,
{ ...provider, ...(provider.family ? providerFamilies[provider.family] : {}) },
]),
),
providers,
}
})
}

View File

@ -2,8 +2,54 @@ import { z } from "zod"
import { fn } from "./util/fn"
import { centsToMicroCents } from "./util/price"
import { getWeekBounds, getMonthlyBounds } from "./util/date"
import { Resource } from "@opencode-ai/console-resource"
export namespace Subscription {
const LimitsSchema = z.object({
free: z.object({
promoTokens: z.number().int(),
dailyRequests: z.number().int(),
checkHeader: z.string(),
fallbackValue: z.number().int(),
}),
lite: z.object({
rollingLimit: z.number().int(),
rollingWindow: z.number().int(),
weeklyLimit: z.number().int(),
monthlyLimit: z.number().int(),
}),
black: z.object({
"20": z.object({
fixedLimit: z.number().int(),
rollingLimit: z.number().int(),
rollingWindow: z.number().int(),
}),
"100": z.object({
fixedLimit: z.number().int(),
rollingLimit: z.number().int(),
rollingWindow: z.number().int(),
}),
"200": z.object({
fixedLimit: z.number().int(),
rollingLimit: z.number().int(),
rollingWindow: z.number().int(),
}),
}),
})
export const validate = fn(LimitsSchema, (input) => {
return input
})
export const getLimits = fn(z.void(), () => {
const json = JSON.parse(Resource.ZEN_LIMITS.value)
return LimitsSchema.parse(json)
})
export const getFreeLimits = fn(z.void(), () => {
return getLimits()["free"]
})
export const analyzeRollingUsage = fn(
z.object({
limit: z.number().int(),

View File

@ -119,10 +119,6 @@ declare module "sst" {
"type": "sst.cloudflare.StaticSite"
"url": string
}
"ZEN_BLACK_LIMITS": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_BLACK_PRICE": {
"plan100": string
"plan20": string
@ -130,7 +126,7 @@ declare module "sst" {
"product": string
"type": "sst.sst.Linkable"
}
"ZEN_LITE_LIMITS": {
"ZEN_LIMITS": {
"type": "sst.sst.Secret"
"value": string
}

View File

@ -119,10 +119,6 @@ declare module "sst" {
"type": "sst.cloudflare.StaticSite"
"url": string
}
"ZEN_BLACK_LIMITS": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_BLACK_PRICE": {
"plan100": string
"plan20": string
@ -130,7 +126,7 @@ declare module "sst" {
"product": string
"type": "sst.sst.Linkable"
}
"ZEN_LITE_LIMITS": {
"ZEN_LIMITS": {
"type": "sst.sst.Secret"
"value": string
}

View File

@ -119,10 +119,6 @@ declare module "sst" {
"type": "sst.cloudflare.StaticSite"
"url": string
}
"ZEN_BLACK_LIMITS": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_BLACK_PRICE": {
"plan100": string
"plan20": string
@ -130,7 +126,7 @@ declare module "sst" {
"product": string
"type": "sst.sst.Linkable"
}
"ZEN_LITE_LIMITS": {
"ZEN_LIMITS": {
"type": "sst.sst.Secret"
"value": string
}

View File

@ -119,10 +119,6 @@ declare module "sst" {
"type": "sst.cloudflare.StaticSite"
"url": string
}
"ZEN_BLACK_LIMITS": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_BLACK_PRICE": {
"plan100": string
"plan20": string
@ -130,7 +126,7 @@ declare module "sst" {
"product": string
"type": "sst.sst.Linkable"
}
"ZEN_LITE_LIMITS": {
"ZEN_LIMITS": {
"type": "sst.sst.Secret"
"value": string
}

View File

@ -119,10 +119,6 @@ declare module "sst" {
"type": "sst.cloudflare.StaticSite"
"url": string
}
"ZEN_BLACK_LIMITS": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_BLACK_PRICE": {
"plan100": string
"plan20": string
@ -130,7 +126,7 @@ declare module "sst" {
"product": string
"type": "sst.sst.Linkable"
}
"ZEN_LITE_LIMITS": {
"ZEN_LIMITS": {
"type": "sst.sst.Secret"
"value": string
}

View File

10
packages/storybook/sst-env.d.ts vendored Normal file
View File

@ -0,0 +1,10 @@
/* This file is auto-generated by SST. Do not edit. */
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
/// <reference path="../../sst-env.d.ts" />
import "sst"
export {}

6
sst-env.d.ts vendored
View File

@ -145,10 +145,6 @@ declare module "sst" {
"type": "sst.cloudflare.StaticSite"
"url": string
}
"ZEN_BLACK_LIMITS": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_BLACK_PRICE": {
"plan100": string
"plan20": string
@ -156,7 +152,7 @@ declare module "sst" {
"product": string
"type": "sst.sst.Linkable"
}
"ZEN_LITE_LIMITS": {
"ZEN_LIMITS": {
"type": "sst.sst.Secret"
"value": string
}