summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorFrank <[email protected]>2026-03-03 00:25:03 -0500
committerFrank <[email protected]>2026-03-03 00:25:03 -0500
commit6aa4928e9e9430f8d1e9b009fd4a64f400fe0da9 (patch)
treed0267534d1bd2e3331fc70ca177be4eef5d85530
parent9f150b07764c44ab5265d7cc2a3fa4e5909094b2 (diff)
downloadopencode-6aa4928e9e9430f8d1e9b009fd4a64f400fe0da9.tar.gz
opencode-6aa4928e9e9430f8d1e9b009fd4a64f400fe0da9.zip
wip: zen
-rw-r--r--infra/console.ts5
-rw-r--r--packages/console/app/src/routes/zen/util/handler.ts26
-rw-r--r--packages/console/app/src/routes/zen/util/rateLimiter.ts52
-rw-r--r--packages/console/app/src/routes/zen/util/trialLimiter.ts15
-rw-r--r--packages/console/app/test/rateLimiter.test.ts75
-rw-r--r--packages/console/core/package.json9
-rw-r--r--packages/console/core/script/black-stats.ts312
-rwxr-xr-xpackages/console/core/script/promote-black.ts22
-rwxr-xr-xpackages/console/core/script/promote-limits.ts (renamed from packages/console/core/script/promote-lite.ts)10
-rwxr-xr-xpackages/console/core/script/update-black.ts28
-rwxr-xr-xpackages/console/core/script/update-limits.ts (renamed from packages/console/core/script/update-lite.ts)12
-rw-r--r--packages/console/core/src/black.ts26
-rw-r--r--packages/console/core/src/lite.ts15
-rw-r--r--packages/console/core/src/model.ts36
-rw-r--r--packages/console/core/src/subscription.ts46
-rw-r--r--packages/console/core/sst-env.d.ts6
-rw-r--r--packages/console/function/sst-env.d.ts6
-rw-r--r--packages/console/resource/sst-env.d.ts6
-rw-r--r--packages/enterprise/sst-env.d.ts6
-rw-r--r--packages/function/sst-env.d.ts6
-rw-r--r--packages/sdk/js/openapi.json0
-rw-r--r--packages/storybook/sst-env.d.ts10
-rw-r--r--sst-env.d.ts6
23 files changed, 422 insertions, 313 deletions
diff --git a/infra/console.ts b/infra/console.ts
index de72cb072..128e06986 100644
--- a/infra/console.ts
+++ b/infra/console.ts
@@ -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
diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts
index d3a25c5f6..429ce0018 100644
--- a/packages/console/app/src/routes/zen/util/handler.ts
+++ b/packages/console/app/src/routes/zen/util/handler.ts
@@ -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) {
diff --git a/packages/console/app/src/routes/zen/util/rateLimiter.ts b/packages/console/app/src/routes/zen/util/rateLimiter.ts
index 6325a7b4d..019e68754 100644
--- a/packages/console/app/src/routes/zen/util/rateLimiter.ts
+++ b/packages/console/app/src/routes/zen/util/rateLimiter.ts
@@ -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)
-}
diff --git a/packages/console/app/src/routes/zen/util/trialLimiter.ts b/packages/console/app/src/routes/zen/util/trialLimiter.ts
index 531e5cf0c..1ae0ab329 100644
--- a/packages/console/app/src/routes/zen/util/trialLimiter.ts
+++ b/packages/console/app/src/routes/zen/util/trialLimiter.ts
@@ -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
diff --git a/packages/console/app/test/rateLimiter.test.ts b/packages/console/app/test/rateLimiter.test.ts
index 864f907d6..5cc97dccf 100644
--- a/packages/console/app/test/rateLimiter.test.ts
+++ b/packages/console/app/test/rateLimiter.test.ts
@@ -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)
- })
-})
diff --git a/packages/console/core/package.json b/packages/console/core/package.json
index 8f65e0c45..99ba32df4 100644
--- a/packages/console/core/package.json
+++ b/packages/console/core/package.json
@@ -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": {
diff --git a/packages/console/core/script/black-stats.ts b/packages/console/core/script/black-stats.ts
new file mode 100644
index 000000000..de7cf5e41
--- /dev/null
+++ b/packages/console/core/script/black-stats.ts
@@ -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, '""')}"`
+}
diff --git a/packages/console/core/script/promote-black.ts b/packages/console/core/script/promote-black.ts
deleted file mode 100755
index 4338d0e42..000000000
--- a/packages/console/core/script/promote-black.ts
+++ /dev/null
@@ -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}`
diff --git a/packages/console/core/script/promote-lite.ts b/packages/console/core/script/promote-limits.ts
index 8fd58c805..f488aba02 100755
--- a/packages/console/core/script/promote-lite.ts
+++ b/packages/console/core/script/promote-limits.ts
@@ -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}`
diff --git a/packages/console/core/script/update-black.ts b/packages/console/core/script/update-black.ts
deleted file mode 100755
index 695a5d3ce..000000000
--- a/packages/console/core/script/update-black.ts
+++ /dev/null
@@ -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}`
diff --git a/packages/console/core/script/update-lite.ts b/packages/console/core/script/update-limits.ts
index 2f3e66835..8f2579312 100755
--- a/packages/console/core/script/update-lite.ts
+++ b/packages/console/core/script/update-limits.ts
@@ -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}`
diff --git a/packages/console/core/src/black.ts b/packages/console/core/src/black.ts
index a18c5258d..1908403a2 100644
--- a/packages/console/core/src/black.ts
+++ b/packages/console/core/src/black.ts
@@ -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]
},
)
diff --git a/packages/console/core/src/lite.ts b/packages/console/core/src/lite.ts
index 49d23e59e..c6f7d5a3e 100644
--- a/packages/console/core/src/lite.ts
+++ b/packages/console/core/src/lite.ts
@@ -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)
diff --git a/packages/console/core/src/model.ts b/packages/console/core/src/model.ts
index e868b176e..e4fa02249 100644
--- a/packages/console/core/src/model.ts
+++ b/packages/console/core/src/model.ts
@@ -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,
}
})
}
diff --git a/packages/console/core/src/subscription.ts b/packages/console/core/src/subscription.ts
index 879f940e0..9d6c3ce2b 100644
--- a/packages/console/core/src/subscription.ts
+++ b/packages/console/core/src/subscription.ts
@@ -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(),
diff --git a/packages/console/core/sst-env.d.ts b/packages/console/core/sst-env.d.ts
index edff904e0..23ae6e44b 100644
--- a/packages/console/core/sst-env.d.ts
+++ b/packages/console/core/sst-env.d.ts
@@ -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
}
diff --git a/packages/console/function/sst-env.d.ts b/packages/console/function/sst-env.d.ts
index edff904e0..23ae6e44b 100644
--- a/packages/console/function/sst-env.d.ts
+++ b/packages/console/function/sst-env.d.ts
@@ -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
}
diff --git a/packages/console/resource/sst-env.d.ts b/packages/console/resource/sst-env.d.ts
index edff904e0..23ae6e44b 100644
--- a/packages/console/resource/sst-env.d.ts
+++ b/packages/console/resource/sst-env.d.ts
@@ -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
}
diff --git a/packages/enterprise/sst-env.d.ts b/packages/enterprise/sst-env.d.ts
index edff904e0..23ae6e44b 100644
--- a/packages/enterprise/sst-env.d.ts
+++ b/packages/enterprise/sst-env.d.ts
@@ -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
}
diff --git a/packages/function/sst-env.d.ts b/packages/function/sst-env.d.ts
index edff904e0..23ae6e44b 100644
--- a/packages/function/sst-env.d.ts
+++ b/packages/function/sst-env.d.ts
@@ -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
}
diff --git a/packages/sdk/js/openapi.json b/packages/sdk/js/openapi.json
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/packages/sdk/js/openapi.json
diff --git a/packages/storybook/sst-env.d.ts b/packages/storybook/sst-env.d.ts
new file mode 100644
index 000000000..64441936d
--- /dev/null
+++ b/packages/storybook/sst-env.d.ts
@@ -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 {} \ No newline at end of file
diff --git a/sst-env.d.ts b/sst-env.d.ts
index fb7a7dc42..c8622a5a9 100644
--- a/sst-env.d.ts
+++ b/sst-env.d.ts
@@ -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
}