summaryrefslogtreecommitdiffhomepage
path: root/packages/console/core/script
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 /packages/console/core/script
parent9f150b07764c44ab5265d7cc2a3fa4e5909094b2 (diff)
downloadopencode-6aa4928e9e9430f8d1e9b009fd4a64f400fe0da9.tar.gz
opencode-6aa4928e9e9430f8d1e9b009fd4a64f400fe0da9.zip
wip: zen
Diffstat (limited to 'packages/console/core/script')
-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
5 files changed, 323 insertions, 61 deletions
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}`