wip: zen
parent
9f150b0776
commit
6aa4928e9e
|
|
@ -118,7 +118,6 @@ const ZEN_LITE_PRICE = new sst.Linkable("ZEN_LITE_PRICE", {
|
||||||
price: zenLitePrice.id,
|
price: zenLitePrice.id,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const ZEN_LITE_LIMITS = new sst.Secret("ZEN_LITE_LIMITS")
|
|
||||||
|
|
||||||
const zenBlackProduct = new stripe.Product("ZenBlack", {
|
const zenBlackProduct = new stripe.Product("ZenBlack", {
|
||||||
name: "OpenCode Black",
|
name: "OpenCode Black",
|
||||||
|
|
@ -142,7 +141,6 @@ const ZEN_BLACK_PRICE = new sst.Linkable("ZEN_BLACK_PRICE", {
|
||||||
plan20: zenBlackPrice20.id,
|
plan20: zenBlackPrice20.id,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const ZEN_BLACK_LIMITS = new sst.Secret("ZEN_BLACK_LIMITS")
|
|
||||||
|
|
||||||
const ZEN_MODELS = [
|
const ZEN_MODELS = [
|
||||||
new sst.Secret("ZEN_MODELS1"),
|
new sst.Secret("ZEN_MODELS1"),
|
||||||
|
|
@ -215,9 +213,8 @@ new sst.cloudflare.x.SolidStart("Console", {
|
||||||
AWS_SES_ACCESS_KEY_ID,
|
AWS_SES_ACCESS_KEY_ID,
|
||||||
AWS_SES_SECRET_ACCESS_KEY,
|
AWS_SES_SECRET_ACCESS_KEY,
|
||||||
ZEN_BLACK_PRICE,
|
ZEN_BLACK_PRICE,
|
||||||
ZEN_BLACK_LIMITS,
|
|
||||||
ZEN_LITE_PRICE,
|
ZEN_LITE_PRICE,
|
||||||
ZEN_LITE_LIMITS,
|
new sst.Secret("ZEN_LIMITS"),
|
||||||
new sst.Secret("ZEN_SESSION_SECRET"),
|
new sst.Secret("ZEN_SESSION_SECRET"),
|
||||||
...ZEN_MODELS,
|
...ZEN_MODELS,
|
||||||
...($dev
|
...($dev
|
||||||
|
|
|
||||||
|
|
@ -97,9 +97,9 @@ export async function handler(
|
||||||
const zenData = ZenData.list(opts.modelList)
|
const zenData = ZenData.list(opts.modelList)
|
||||||
const modelInfo = validateModel(zenData, model)
|
const modelInfo = validateModel(zenData, model)
|
||||||
const dataDumper = createDataDumper(sessionId, requestId, projectId)
|
const dataDumper = createDataDumper(sessionId, requestId, projectId)
|
||||||
const trialLimiter = createTrialLimiter(modelInfo.trial, ip, ocClient)
|
const trialLimiter = createTrialLimiter(modelInfo.trialProvider, ip)
|
||||||
const isTrial = await trialLimiter?.isTrial()
|
const trialProvider = await trialLimiter?.check()
|
||||||
const rateLimiter = createRateLimiter(modelInfo.rateLimit, ip, input.request)
|
const rateLimiter = createRateLimiter(modelInfo.allowAnonymous, ip, input.request)
|
||||||
await rateLimiter?.check()
|
await rateLimiter?.check()
|
||||||
const stickyTracker = createStickyTracker(modelInfo.stickyProvider, sessionId)
|
const stickyTracker = createStickyTracker(modelInfo.stickyProvider, sessionId)
|
||||||
const stickyProvider = await stickyTracker?.get()
|
const stickyProvider = await stickyTracker?.get()
|
||||||
|
|
@ -114,7 +114,7 @@ export async function handler(
|
||||||
authInfo,
|
authInfo,
|
||||||
modelInfo,
|
modelInfo,
|
||||||
sessionId,
|
sessionId,
|
||||||
isTrial ?? false,
|
trialProvider,
|
||||||
retry,
|
retry,
|
||||||
stickyProvider,
|
stickyProvider,
|
||||||
)
|
)
|
||||||
|
|
@ -144,9 +144,6 @@ export async function handler(
|
||||||
Object.entries(providerInfo.headerMappings ?? {}).forEach(([k, v]) => {
|
Object.entries(providerInfo.headerMappings ?? {}).forEach(([k, v]) => {
|
||||||
headers.set(k, headers.get(v)!)
|
headers.set(k, headers.get(v)!)
|
||||||
})
|
})
|
||||||
Object.entries(providerInfo.headers ?? {}).forEach(([k, v]) => {
|
|
||||||
headers.set(k, v)
|
|
||||||
})
|
|
||||||
headers.delete("host")
|
headers.delete("host")
|
||||||
headers.delete("content-length")
|
headers.delete("content-length")
|
||||||
headers.delete("x-opencode-request")
|
headers.delete("x-opencode-request")
|
||||||
|
|
@ -295,18 +292,13 @@ export async function handler(
|
||||||
part = part.trim()
|
part = part.trim()
|
||||||
usageParser.parse(part)
|
usageParser.parse(part)
|
||||||
|
|
||||||
if (providerInfo.responseModifier) {
|
if (providerInfo.format !== opts.format) {
|
||||||
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) {
|
|
||||||
part = streamConverter(part)
|
part = streamConverter(part)
|
||||||
c.enqueue(encoder.encode(part + "\n\n"))
|
c.enqueue(encoder.encode(part + "\n\n"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!providerInfo.responseModifier && providerInfo.format === opts.format) {
|
if (providerInfo.format === opts.format) {
|
||||||
c.enqueue(value)
|
c.enqueue(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -398,7 +390,7 @@ export async function handler(
|
||||||
authInfo: AuthInfo,
|
authInfo: AuthInfo,
|
||||||
modelInfo: ModelInfo,
|
modelInfo: ModelInfo,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
isTrial: boolean,
|
trialProvider: string | undefined,
|
||||||
retry: RetryOptions,
|
retry: RetryOptions,
|
||||||
stickyProvider: string | undefined,
|
stickyProvider: string | undefined,
|
||||||
) {
|
) {
|
||||||
|
|
@ -407,8 +399,8 @@ export async function handler(
|
||||||
return modelInfo.providers.find((provider) => provider.id === modelInfo.byokProvider)
|
return modelInfo.providers.find((provider) => provider.id === modelInfo.byokProvider)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isTrial) {
|
if (trialProvider) {
|
||||||
return modelInfo.providers.find((provider) => provider.id === modelInfo.trial!.provider)
|
return modelInfo.providers.find((provider) => provider.id === trialProvider)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stickyProvider) {
|
if (stickyProvider) {
|
||||||
|
|
|
||||||
|
|
@ -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 { IpRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js"
|
||||||
import { FreeUsageLimitError } from "./error"
|
import { FreeUsageLimitError } from "./error"
|
||||||
import { logger } from "./logger"
|
import { logger } from "./logger"
|
||||||
import { ZenData } from "@opencode-ai/console-core/model.js"
|
|
||||||
import { i18n } from "~/i18n"
|
import { i18n } from "~/i18n"
|
||||||
import { localeFromRequest } from "~/lib/language"
|
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) {
|
export function createRateLimiter(allowAnonymous: boolean | undefined, rawIp: string, request: Request) {
|
||||||
if (!limit) return
|
if (!allowAnonymous) return
|
||||||
const dict = i18n(localeFromRequest(request))
|
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 ip = !rawIp.length ? "unknown" : rawIp
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const intervals =
|
const interval = buildYYYYMMDD(now)
|
||||||
limit.period === "day"
|
|
||||||
? [buildYYYYMMDD(now)]
|
|
||||||
: [buildYYYYMMDDHH(now), buildYYYYMMDDHH(now - 3_600_000), buildYYYYMMDDHH(now - 7_200_000)]
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
track: async () => {
|
track: async () => {
|
||||||
await Database.use((tx) =>
|
await Database.use((tx) =>
|
||||||
tx
|
tx
|
||||||
.insert(IpRateLimitTable)
|
.insert(IpRateLimitTable)
|
||||||
.values({ ip, interval: intervals[0], count: 1 })
|
.values({ ip, interval, count: 1 })
|
||||||
.onDuplicateKeyUpdate({ set: { count: sql`${IpRateLimitTable.count} + 1` } }),
|
.onDuplicateKeyUpdate({ set: { count: sql`${IpRateLimitTable.count} + 1` } }),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
@ -33,15 +32,12 @@ export function createRateLimiter(limit: ZenData.RateLimit | undefined, rawIp: s
|
||||||
tx
|
tx
|
||||||
.select({ interval: IpRateLimitTable.interval, count: IpRateLimitTable.count })
|
.select({ interval: IpRateLimitTable.interval, count: IpRateLimitTable.count })
|
||||||
.from(IpRateLimitTable)
|
.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)
|
const total = rows.reduce((sum, r) => sum + r.count, 0)
|
||||||
logger.debug(`rate limit total: ${total}`)
|
logger.debug(`rate limit total: ${total}`)
|
||||||
if (total >= limitValue)
|
if (total >= limitValue)
|
||||||
throw new FreeUsageLimitError(
|
throw new FreeUsageLimitError(dict["zen.api.error.rateLimitExceeded"], getRetryAfterDay(now))
|
||||||
dict["zen.api.error.rateLimitExceeded"],
|
|
||||||
limit.period === "day" ? getRetryAfterDay(now) : getRetryAfterHour(rows, intervals, limitValue, now),
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -50,37 +46,9 @@ export function getRetryAfterDay(now: number) {
|
||||||
return Math.ceil((86_400_000 - (now % 86_400_000)) / 1000)
|
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) {
|
function buildYYYYMMDD(timestamp: number) {
|
||||||
return new Date(timestamp)
|
return new Date(timestamp)
|
||||||
.toISOString()
|
.toISOString()
|
||||||
.replace(/[^0-9]/g, "")
|
.replace(/[^0-9]/g, "")
|
||||||
.substring(0, 8)
|
.substring(0, 8)
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildYYYYMMDDHH(timestamp: number) {
|
|
||||||
return new Date(timestamp)
|
|
||||||
.toISOString()
|
|
||||||
.replace(/[^0-9]/g, "")
|
|
||||||
.substring(0, 10)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,18 @@
|
||||||
import { Database, eq, sql } from "@opencode-ai/console-core/drizzle/index.js"
|
import { Database, eq, sql } from "@opencode-ai/console-core/drizzle/index.js"
|
||||||
import { IpTable } from "@opencode-ai/console-core/schema/ip.sql.js"
|
import { IpTable } from "@opencode-ai/console-core/schema/ip.sql.js"
|
||||||
import { UsageInfo } from "./provider/provider"
|
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) {
|
export function createTrialLimiter(trialProvider: string | undefined, ip: string) {
|
||||||
if (!trial) return
|
if (!trialProvider) return
|
||||||
if (!ip) return
|
if (!ip) return
|
||||||
|
|
||||||
const limit =
|
const limit = Subscription.getFreeLimits().promoTokens
|
||||||
trial.limits.find((limit) => limit.client === client)?.limit ??
|
|
||||||
trial.limits.find((limit) => limit.client === undefined)?.limit
|
|
||||||
if (!limit) return
|
|
||||||
|
|
||||||
let _isTrial: boolean
|
let _isTrial: boolean
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isTrial: async () => {
|
check: async () => {
|
||||||
const data = await Database.use((tx) =>
|
const data = await Database.use((tx) =>
|
||||||
tx
|
tx
|
||||||
.select({
|
.select({
|
||||||
|
|
@ -27,7 +24,7 @@ export function createTrialLimiter(trial: ZenData.Trial | undefined, ip: string,
|
||||||
)
|
)
|
||||||
|
|
||||||
_isTrial = (data?.usage ?? 0) < limit
|
_isTrial = (data?.usage ?? 0) < limit
|
||||||
return _isTrial
|
return _isTrial ? trialProvider : undefined
|
||||||
},
|
},
|
||||||
track: async (usageInfo: UsageInfo) => {
|
track: async (usageInfo: UsageInfo) => {
|
||||||
if (!_isTrial) return
|
if (!_isTrial) return
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, expect, test } from "bun:test"
|
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", () => {
|
describe("getRetryAfterDay", () => {
|
||||||
test("returns full day at midnight UTC", () => {
|
test("returns full day at midnight UTC", () => {
|
||||||
|
|
@ -17,76 +17,3 @@ describe("getRetryAfterDay", () => {
|
||||||
expect(getRetryAfterDay(almost)).toBe(1)
|
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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
|
||||||
|
|
@ -34,12 +34,9 @@
|
||||||
"promote-models-to-prod": "script/promote-models.ts production",
|
"promote-models-to-prod": "script/promote-models.ts production",
|
||||||
"pull-models-from-dev": "script/pull-models.ts dev",
|
"pull-models-from-dev": "script/pull-models.ts dev",
|
||||||
"pull-models-from-prod": "script/pull-models.ts production",
|
"pull-models-from-prod": "script/pull-models.ts production",
|
||||||
"update-black": "script/update-black.ts",
|
"update-limits": "script/update-limits.ts",
|
||||||
"promote-black-to-dev": "script/promote-black.ts dev",
|
"promote-limits-to-dev": "script/promote-limits.ts dev",
|
||||||
"promote-black-to-prod": "script/promote-black.ts production",
|
"promote-limits-to-prod": "script/promote-limits.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",
|
|
||||||
"typecheck": "tsgo --noEmit"
|
"typecheck": "tsgo --noEmit"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -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, '""')}"`
|
||||||
|
}
|
||||||
|
|
@ -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}`
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { $ } from "bun"
|
import { $ } from "bun"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { LiteData } from "../src/lite"
|
import { Subscription } from "../src/subscription"
|
||||||
|
|
||||||
const stage = process.argv[2]
|
const stage = process.argv[2]
|
||||||
if (!stage) throw new Error("Stage is required")
|
if (!stage) throw new Error("Stage is required")
|
||||||
|
|
@ -12,11 +12,11 @@ const root = path.resolve(process.cwd(), "..", "..", "..")
|
||||||
// read the secret
|
// read the secret
|
||||||
const ret = await $`bun sst secret list`.cwd(root).text()
|
const ret = await $`bun sst secret list`.cwd(root).text()
|
||||||
const lines = ret.split("\n")
|
const lines = ret.split("\n")
|
||||||
const value = lines.find((line) => line.startsWith("ZEN_LITE_LIMITS"))?.split("=")[1]
|
const value = lines.find((line) => line.startsWith("ZEN_LIMITS"))?.split("=")[1]
|
||||||
if (!value) throw new Error("ZEN_LITE_LIMITS not found")
|
if (!value) throw new Error("ZEN_LIMITS not found")
|
||||||
|
|
||||||
// validate value
|
// validate value
|
||||||
LiteData.validate(JSON.parse(value))
|
Subscription.validate(JSON.parse(value))
|
||||||
|
|
||||||
// update the secret
|
// update the secret
|
||||||
await $`bun sst secret set ZEN_LITE_LIMITS ${value} --stage ${stage}`
|
await $`bun sst secret set ZEN_LIMITS ${value} --stage ${stage}`
|
||||||
|
|
@ -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}`
|
|
||||||
|
|
@ -3,18 +3,18 @@
|
||||||
import { $ } from "bun"
|
import { $ } from "bun"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import os from "os"
|
import os from "os"
|
||||||
import { LiteData } from "../src/lite"
|
import { Subscription } from "../src/subscription"
|
||||||
|
|
||||||
const root = path.resolve(process.cwd(), "..", "..", "..")
|
const root = path.resolve(process.cwd(), "..", "..", "..")
|
||||||
const secrets = await $`bun sst secret list`.cwd(root).text()
|
const secrets = await $`bun sst secret list`.cwd(root).text()
|
||||||
|
|
||||||
// read value
|
// read value
|
||||||
const lines = secrets.split("\n")
|
const lines = secrets.split("\n")
|
||||||
const oldValue = lines.find((line) => line.startsWith("ZEN_LITE_LIMITS"))?.split("=")[1] ?? "{}"
|
const oldValue = lines.find((line) => line.startsWith("ZEN_LIMITS"))?.split("=")[1] ?? "{}"
|
||||||
if (!oldValue) throw new Error("ZEN_LITE_LIMITS not found")
|
if (!oldValue) throw new Error("ZEN_LIMITS not found")
|
||||||
|
|
||||||
// store the prettified json to a temp file
|
// 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))
|
const tempFile = Bun.file(path.join(os.tmpdir(), filename))
|
||||||
await tempFile.write(JSON.stringify(JSON.parse(oldValue), null, 2))
|
await tempFile.write(JSON.stringify(JSON.parse(oldValue), null, 2))
|
||||||
console.log("tempFile", tempFile.name)
|
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
|
// open temp file in vim and read the file on close
|
||||||
await $`vim ${tempFile.name}`
|
await $`vim ${tempFile.name}`
|
||||||
const newValue = JSON.stringify(JSON.parse(await tempFile.text()))
|
const newValue = JSON.stringify(JSON.parse(await tempFile.text()))
|
||||||
LiteData.validate(JSON.parse(newValue))
|
Subscription.validate(JSON.parse(newValue))
|
||||||
|
|
||||||
// update the secret
|
// update the secret
|
||||||
await $`bun sst secret set ZEN_LITE_LIMITS ${newValue}`
|
await $`bun sst secret set ZEN_LIMITS ${newValue}`
|
||||||
|
|
@ -2,37 +2,15 @@ import { z } from "zod"
|
||||||
import { fn } from "./util/fn"
|
import { fn } from "./util/fn"
|
||||||
import { Resource } from "@opencode-ai/console-resource"
|
import { Resource } from "@opencode-ai/console-resource"
|
||||||
import { BlackPlans } from "./schema/billing.sql"
|
import { BlackPlans } from "./schema/billing.sql"
|
||||||
|
import { Subscription } from "./subscription"
|
||||||
|
|
||||||
export namespace BlackData {
|
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(
|
export const getLimits = fn(
|
||||||
z.object({
|
z.object({
|
||||||
plan: z.enum(BlackPlans),
|
plan: z.enum(BlackPlans),
|
||||||
}),
|
}),
|
||||||
({ plan }) => {
|
({ plan }) => {
|
||||||
const json = JSON.parse(Resource.ZEN_BLACK_LIMITS.value)
|
return Subscription.getLimits()["black"][plan]
|
||||||
return Schema.parse(json)[plan]
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,11 @@
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { fn } from "./util/fn"
|
import { fn } from "./util/fn"
|
||||||
import { Resource } from "@opencode-ai/console-resource"
|
import { Resource } from "@opencode-ai/console-resource"
|
||||||
|
import { Subscription } from "./subscription"
|
||||||
|
|
||||||
export namespace LiteData {
|
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(), () => {
|
export const getLimits = fn(z.void(), () => {
|
||||||
const json = JSON.parse(Resource.ZEN_LITE_LIMITS.value)
|
return Subscription.getLimits()["lite"]
|
||||||
return Schema.parse(json)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export const productID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.product)
|
export const productID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.product)
|
||||||
|
|
|
||||||
|
|
@ -9,24 +9,7 @@ import { Resource } from "@opencode-ai/console-resource"
|
||||||
|
|
||||||
export namespace ZenData {
|
export namespace ZenData {
|
||||||
const FormatSchema = z.enum(["anthropic", "google", "openai", "oa-compat"])
|
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 Format = z.infer<typeof FormatSchema>
|
||||||
export type Trial = z.infer<typeof TrialSchema>
|
|
||||||
export type RateLimit = z.infer<typeof RateLimitSchema>
|
|
||||||
|
|
||||||
const ModelCostSchema = z.object({
|
const ModelCostSchema = z.object({
|
||||||
input: z.number(),
|
input: z.number(),
|
||||||
|
|
@ -43,8 +26,7 @@ export namespace ZenData {
|
||||||
allowAnonymous: z.boolean().optional(),
|
allowAnonymous: z.boolean().optional(),
|
||||||
byokProvider: z.enum(["openai", "anthropic", "google"]).optional(),
|
byokProvider: z.enum(["openai", "anthropic", "google"]).optional(),
|
||||||
stickyProvider: z.enum(["strict", "prefer"]).optional(),
|
stickyProvider: z.enum(["strict", "prefer"]).optional(),
|
||||||
trial: TrialSchema.optional(),
|
trialProvider: z.string().optional(),
|
||||||
rateLimit: RateLimitSchema.optional(),
|
|
||||||
fallbackProvider: z.string().optional(),
|
fallbackProvider: z.string().optional(),
|
||||||
providers: z.array(
|
providers: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
|
|
@ -63,19 +45,12 @@ export namespace ZenData {
|
||||||
format: FormatSchema.optional(),
|
format: FormatSchema.optional(),
|
||||||
headerMappings: z.record(z.string(), z.string()).optional(),
|
headerMappings: z.record(z.string(), z.string()).optional(),
|
||||||
payloadModifier: z.record(z.string(), z.any()).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({
|
const ModelsSchema = z.object({
|
||||||
models: z.record(z.string(), z.union([ModelSchema, z.array(ModelSchema.extend({ formatFilter: FormatSchema }))])),
|
models: z.record(z.string(), z.union([ModelSchema, z.array(ModelSchema.extend({ formatFilter: FormatSchema }))])),
|
||||||
liteModels: z.record(z.string(), ModelSchema),
|
liteModels: z.record(z.string(), ModelSchema),
|
||||||
providers: z.record(z.string(), ProviderSchema),
|
providers: z.record(z.string(), ProviderSchema),
|
||||||
providerFamilies: z.record(z.string(), ProviderFamilySchema),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export const validate = fn(ModelsSchema, (input) => {
|
export const validate = fn(ModelsSchema, (input) => {
|
||||||
|
|
@ -115,15 +90,10 @@ export namespace ZenData {
|
||||||
Resource.ZEN_MODELS29.value +
|
Resource.ZEN_MODELS29.value +
|
||||||
Resource.ZEN_MODELS30.value,
|
Resource.ZEN_MODELS30.value,
|
||||||
)
|
)
|
||||||
const { models, liteModels, providers, providerFamilies } = ModelsSchema.parse(json)
|
const { models, liteModels, providers } = ModelsSchema.parse(json)
|
||||||
return {
|
return {
|
||||||
models: modelList === "lite" ? liteModels : models,
|
models: modelList === "lite" ? liteModels : models,
|
||||||
providers: Object.fromEntries(
|
providers,
|
||||||
Object.entries(providers).map(([id, provider]) => [
|
|
||||||
id,
|
|
||||||
{ ...provider, ...(provider.family ? providerFamilies[provider.family] : {}) },
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,54 @@ import { z } from "zod"
|
||||||
import { fn } from "./util/fn"
|
import { fn } from "./util/fn"
|
||||||
import { centsToMicroCents } from "./util/price"
|
import { centsToMicroCents } from "./util/price"
|
||||||
import { getWeekBounds, getMonthlyBounds } from "./util/date"
|
import { getWeekBounds, getMonthlyBounds } from "./util/date"
|
||||||
|
import { Resource } from "@opencode-ai/console-resource"
|
||||||
|
|
||||||
export namespace Subscription {
|
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(
|
export const analyzeRollingUsage = fn(
|
||||||
z.object({
|
z.object({
|
||||||
limit: z.number().int(),
|
limit: z.number().int(),
|
||||||
|
|
|
||||||
|
|
@ -119,10 +119,6 @@ declare module "sst" {
|
||||||
"type": "sst.cloudflare.StaticSite"
|
"type": "sst.cloudflare.StaticSite"
|
||||||
"url": string
|
"url": string
|
||||||
}
|
}
|
||||||
"ZEN_BLACK_LIMITS": {
|
|
||||||
"type": "sst.sst.Secret"
|
|
||||||
"value": string
|
|
||||||
}
|
|
||||||
"ZEN_BLACK_PRICE": {
|
"ZEN_BLACK_PRICE": {
|
||||||
"plan100": string
|
"plan100": string
|
||||||
"plan20": string
|
"plan20": string
|
||||||
|
|
@ -130,7 +126,7 @@ declare module "sst" {
|
||||||
"product": string
|
"product": string
|
||||||
"type": "sst.sst.Linkable"
|
"type": "sst.sst.Linkable"
|
||||||
}
|
}
|
||||||
"ZEN_LITE_LIMITS": {
|
"ZEN_LIMITS": {
|
||||||
"type": "sst.sst.Secret"
|
"type": "sst.sst.Secret"
|
||||||
"value": string
|
"value": string
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -119,10 +119,6 @@ declare module "sst" {
|
||||||
"type": "sst.cloudflare.StaticSite"
|
"type": "sst.cloudflare.StaticSite"
|
||||||
"url": string
|
"url": string
|
||||||
}
|
}
|
||||||
"ZEN_BLACK_LIMITS": {
|
|
||||||
"type": "sst.sst.Secret"
|
|
||||||
"value": string
|
|
||||||
}
|
|
||||||
"ZEN_BLACK_PRICE": {
|
"ZEN_BLACK_PRICE": {
|
||||||
"plan100": string
|
"plan100": string
|
||||||
"plan20": string
|
"plan20": string
|
||||||
|
|
@ -130,7 +126,7 @@ declare module "sst" {
|
||||||
"product": string
|
"product": string
|
||||||
"type": "sst.sst.Linkable"
|
"type": "sst.sst.Linkable"
|
||||||
}
|
}
|
||||||
"ZEN_LITE_LIMITS": {
|
"ZEN_LIMITS": {
|
||||||
"type": "sst.sst.Secret"
|
"type": "sst.sst.Secret"
|
||||||
"value": string
|
"value": string
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -119,10 +119,6 @@ declare module "sst" {
|
||||||
"type": "sst.cloudflare.StaticSite"
|
"type": "sst.cloudflare.StaticSite"
|
||||||
"url": string
|
"url": string
|
||||||
}
|
}
|
||||||
"ZEN_BLACK_LIMITS": {
|
|
||||||
"type": "sst.sst.Secret"
|
|
||||||
"value": string
|
|
||||||
}
|
|
||||||
"ZEN_BLACK_PRICE": {
|
"ZEN_BLACK_PRICE": {
|
||||||
"plan100": string
|
"plan100": string
|
||||||
"plan20": string
|
"plan20": string
|
||||||
|
|
@ -130,7 +126,7 @@ declare module "sst" {
|
||||||
"product": string
|
"product": string
|
||||||
"type": "sst.sst.Linkable"
|
"type": "sst.sst.Linkable"
|
||||||
}
|
}
|
||||||
"ZEN_LITE_LIMITS": {
|
"ZEN_LIMITS": {
|
||||||
"type": "sst.sst.Secret"
|
"type": "sst.sst.Secret"
|
||||||
"value": string
|
"value": string
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -119,10 +119,6 @@ declare module "sst" {
|
||||||
"type": "sst.cloudflare.StaticSite"
|
"type": "sst.cloudflare.StaticSite"
|
||||||
"url": string
|
"url": string
|
||||||
}
|
}
|
||||||
"ZEN_BLACK_LIMITS": {
|
|
||||||
"type": "sst.sst.Secret"
|
|
||||||
"value": string
|
|
||||||
}
|
|
||||||
"ZEN_BLACK_PRICE": {
|
"ZEN_BLACK_PRICE": {
|
||||||
"plan100": string
|
"plan100": string
|
||||||
"plan20": string
|
"plan20": string
|
||||||
|
|
@ -130,7 +126,7 @@ declare module "sst" {
|
||||||
"product": string
|
"product": string
|
||||||
"type": "sst.sst.Linkable"
|
"type": "sst.sst.Linkable"
|
||||||
}
|
}
|
||||||
"ZEN_LITE_LIMITS": {
|
"ZEN_LIMITS": {
|
||||||
"type": "sst.sst.Secret"
|
"type": "sst.sst.Secret"
|
||||||
"value": string
|
"value": string
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -119,10 +119,6 @@ declare module "sst" {
|
||||||
"type": "sst.cloudflare.StaticSite"
|
"type": "sst.cloudflare.StaticSite"
|
||||||
"url": string
|
"url": string
|
||||||
}
|
}
|
||||||
"ZEN_BLACK_LIMITS": {
|
|
||||||
"type": "sst.sst.Secret"
|
|
||||||
"value": string
|
|
||||||
}
|
|
||||||
"ZEN_BLACK_PRICE": {
|
"ZEN_BLACK_PRICE": {
|
||||||
"plan100": string
|
"plan100": string
|
||||||
"plan20": string
|
"plan20": string
|
||||||
|
|
@ -130,7 +126,7 @@ declare module "sst" {
|
||||||
"product": string
|
"product": string
|
||||||
"type": "sst.sst.Linkable"
|
"type": "sst.sst.Linkable"
|
||||||
}
|
}
|
||||||
"ZEN_LITE_LIMITS": {
|
"ZEN_LIMITS": {
|
||||||
"type": "sst.sst.Secret"
|
"type": "sst.sst.Secret"
|
||||||
"value": string
|
"value": string
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {}
|
||||||
|
|
@ -145,10 +145,6 @@ declare module "sst" {
|
||||||
"type": "sst.cloudflare.StaticSite"
|
"type": "sst.cloudflare.StaticSite"
|
||||||
"url": string
|
"url": string
|
||||||
}
|
}
|
||||||
"ZEN_BLACK_LIMITS": {
|
|
||||||
"type": "sst.sst.Secret"
|
|
||||||
"value": string
|
|
||||||
}
|
|
||||||
"ZEN_BLACK_PRICE": {
|
"ZEN_BLACK_PRICE": {
|
||||||
"plan100": string
|
"plan100": string
|
||||||
"plan20": string
|
"plan20": string
|
||||||
|
|
@ -156,7 +152,7 @@ declare module "sst" {
|
||||||
"product": string
|
"product": string
|
||||||
"type": "sst.sst.Linkable"
|
"type": "sst.sst.Linkable"
|
||||||
}
|
}
|
||||||
"ZEN_LITE_LIMITS": {
|
"ZEN_LIMITS": {
|
||||||
"type": "sst.sst.Secret"
|
"type": "sst.sst.Secret"
|
||||||
"value": string
|
"value": string
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue