Frank 1 месяц назад
Родитель
Сommit
6aa4928e9e

+ 1 - 4
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

+ 9 - 17
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) {

+ 10 - 42
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)
-}

+ 6 - 9
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

+ 1 - 74
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)
-  })
-})

+ 3 - 6
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": {

+ 312 - 0
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, '""')}"`
+}

+ 0 - 22
packages/console/core/script/promote-black.ts

@@ -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}`

+ 5 - 5
packages/console/core/script/promote-lite.ts → 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}`

+ 0 - 28
packages/console/core/script/update-black.ts

@@ -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}`

+ 6 - 6
packages/console/core/script/update-lite.ts → 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}`

+ 2 - 24
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]
     },
   )
 

+ 2 - 13
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)

+ 3 - 33
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,
     }
   })
 }

+ 46 - 0
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(),

+ 1 - 5
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
     }

+ 1 - 5
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
     }

+ 1 - 5
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
     }

+ 1 - 5
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
     }

+ 1 - 5
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
     }

+ 0 - 0
packages/sdk/js/openapi.json


+ 10 - 0
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 {}

+ 1 - 5
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
     }