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 ") + process.exit(1) +} +const plan = process.argv[2] as (typeof BlackPlans)[number] +if (!BlackPlans.includes(plan)) { + console.error("Usage: bun black-stats.ts ") + 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`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`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`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`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`COALESCE(SUM(${UsageTable.inputTokens}), 0)`, + cacheRead: sql`COALESCE(SUM(${UsageTable.cacheReadTokens}), 0)`, + output: sql`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>() +const totals = new Map() +const ppuMap = new Map>() +const ppuTotals = new Map() +const modelMap = new Map() +const tokenMap = new Map>() + +for (const row of spend) { + const workspace = spendMap.get(row.workspaceID) ?? new Map() + 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() + 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() +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() + const ppuWorkspace = ppuMap.get(user.workspaceID) ?? new Map() + 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 similarity index 55% rename from packages/console/core/script/promote-lite.ts rename to 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 similarity index 65% rename from packages/console/core/script/update-lite.ts rename to 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 - export type Trial = z.infer - export type RateLimit = z.infer 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 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 */ + +/// + +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 }