wip: black

This commit is contained in:
Frank
2026-01-06 23:34:10 -05:00
parent 22b058a33d
commit 23fc675ad5
25 changed files with 2705 additions and 50 deletions

View File

@@ -119,6 +119,7 @@ const ZEN_MODELS = [
new sst.Secret("ZEN_MODELS6"),
new sst.Secret("ZEN_MODELS7"),
]
const ZEN_BLACK = new sst.Secret("ZEN_BLACK")
const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY")
const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", {
properties: { value: auth.url.apply((url) => url!) },
@@ -160,6 +161,7 @@ new sst.cloudflare.x.SolidStart("Console", {
EMAILOCTOPUS_API_KEY,
AWS_SES_ACCESS_KEY_ID,
AWS_SES_SECRET_ACCESS_KEY,
ZEN_BLACK,
...ZEN_MODELS,
...($dev
? [

View File

@@ -0,0 +1,2 @@
.root {
}

View File

@@ -0,0 +1,12 @@
import styles from "./black-section.module.css"
export function BlackSection() {
return (
<section class={styles.root}>
<div data-slot="section-title">
<h2>Black</h2>
<p>You are subscribed to Black.</p>
</div>
</section>
)
}

View File

@@ -2,19 +2,23 @@ import { MonthlyLimitSection } from "./monthly-limit-section"
import { BillingSection } from "./billing-section"
import { ReloadSection } from "./reload-section"
import { PaymentSection } from "./payment-section"
import { BlackSection } from "./black-section"
import { Show } from "solid-js"
import { createAsync, useParams } from "@solidjs/router"
import { queryBillingInfo, querySessionInfo } from "../../common"
export default function () {
const params = useParams()
const userInfo = createAsync(() => querySessionInfo(params.id!))
const sessionInfo = createAsync(() => querySessionInfo(params.id!))
const billingInfo = createAsync(() => queryBillingInfo(params.id!))
return (
<div data-page="workspace-[id]">
<div data-slot="sections">
<Show when={userInfo()?.isAdmin}>
<Show when={sessionInfo()?.isAdmin}>
<Show when={sessionInfo()?.isBeta && billingInfo()?.subscriptionID}>
<BlackSection />
</Show>
<BillingSection />
<Show when={billingInfo()?.customerID}>
<ReloadSection />

View File

@@ -169,7 +169,9 @@ export function UsageSection() {
</Show>
</div>
</td>
<td data-slot="usage-cost">${((usage.cost ?? 0) / 100000000).toFixed(4)}</td>
<td data-slot="usage-cost">
${usage.enrichment?.plan === "sub" ? "0.0000" : ((usage.cost ?? 0) / 100000000).toFixed(4)}
</td>
</tr>
)
}}

View File

@@ -1,6 +1,13 @@
export class AuthError extends Error {}
export class CreditsError extends Error {}
export class MonthlyLimitError extends Error {}
export class SubscriptionError extends Error {
retryAfter?: number
constructor(message: string, retryAfter?: number) {
super(message)
this.retryAfter = retryAfter
}
}
export class UserLimitError extends Error {}
export class ModelError extends Error {}
export class RateLimitError extends Error {}

View File

@@ -8,11 +8,20 @@ import { Billing } from "@opencode-ai/console-core/billing.js"
import { Actor } from "@opencode-ai/console-core/actor.js"
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
import { ZenData } from "@opencode-ai/console-core/model.js"
import { BlackData } from "@opencode-ai/console-core/black.js"
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
import { ModelTable } from "@opencode-ai/console-core/schema/model.sql.js"
import { ProviderTable } from "@opencode-ai/console-core/schema/provider.sql.js"
import { logger } from "./logger"
import { AuthError, CreditsError, MonthlyLimitError, UserLimitError, ModelError, RateLimitError } from "./error"
import {
AuthError,
CreditsError,
MonthlyLimitError,
SubscriptionError,
UserLimitError,
ModelError,
RateLimitError,
} from "./error"
import { createBodyConverter, createStreamPartConverter, createResponseConverter, UsageInfo } from "./provider/provider"
import { anthropicHelper } from "./provider/anthropic"
import { googleHelper } from "./provider/google"
@@ -73,9 +82,9 @@ export async function handler(
await rateLimiter?.check()
const stickyTracker = createStickyTracker(modelInfo.stickyProvider ?? false, sessionId)
const stickyProvider = await stickyTracker?.get()
const authInfo = await authenticate(modelInfo)
const retriableRequest = async (retry: RetryOptions = { excludeProviders: [], retryCount: 0 }) => {
const authInfo = await authenticate(modelInfo)
const providerInfo = selectProvider(
zenData,
authInfo,
@@ -135,10 +144,10 @@ export async function handler(
})
}
return { providerInfo, authInfo, reqBody, res, startTimestamp }
return { providerInfo, reqBody, res, startTimestamp }
}
const { providerInfo, authInfo, reqBody, res, startTimestamp } = await retriableRequest()
const { providerInfo, reqBody, res, startTimestamp } = await retriableRequest()
// Store model request
dataDumper?.provideModel(providerInfo.storeModel)
@@ -279,14 +288,19 @@ export async function handler(
{ status: 401 },
)
if (error instanceof RateLimitError)
if (error instanceof RateLimitError || error instanceof SubscriptionError) {
const headers = new Headers()
if (error instanceof SubscriptionError && error.retryAfter) {
headers.set("retry-after", String(error.retryAfter))
}
return new Response(
JSON.stringify({
type: "error",
error: { type: error.constructor.name, message: error.message },
}),
{ status: 429 },
{ status: 429, headers },
)
}
return new Response(
JSON.stringify({
@@ -400,6 +414,13 @@ export async function handler(
monthlyUsage: UserTable.monthlyUsage,
timeMonthlyUsageUpdated: UserTable.timeMonthlyUsageUpdated,
},
subscription: {
timeSubscribed: UserTable.timeSubscribed,
subIntervalUsage: UserTable.subIntervalUsage,
subMonthlyUsage: UserTable.subMonthlyUsage,
timeSubIntervalUsageUpdated: UserTable.timeSubIntervalUsageUpdated,
timeSubMonthlyUsageUpdated: UserTable.timeSubMonthlyUsageUpdated,
},
provider: {
credentials: ProviderTable.credentials,
},
@@ -427,6 +448,7 @@ export async function handler(
logger.metric({
api_key: data.apiKey,
workspace: data.workspaceID,
isSubscription: data.subscription.timeSubscribed ? true : false,
})
return {
@@ -434,6 +456,7 @@ export async function handler(
workspaceID: data.workspaceID,
billing: data.billing,
user: data.user,
subscription: data.subscription.timeSubscribed ? data.subscription : undefined,
provider: data.provider,
isFree: FREE_WORKSPACES.includes(data.workspaceID),
isDisabled: !!data.timeDisabled,
@@ -446,6 +469,45 @@ export async function handler(
if (authInfo.isFree) return
if (modelInfo.allowAnonymous) return
// Validate subscription billing
if (authInfo.subscription) {
const black = BlackData.get()
const sub = authInfo.subscription
const now = new Date()
// Check monthly limit
if (
sub.subMonthlyUsage &&
sub.timeSubMonthlyUsageUpdated &&
sub.subMonthlyUsage >= centsToMicroCents(black.monthlyLimit * 100) &&
now.getUTCFullYear() === sub.timeSubMonthlyUsageUpdated.getUTCFullYear() &&
now.getUTCMonth() === sub.timeSubMonthlyUsageUpdated.getUTCMonth()
) {
const nextMonth = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1))
throw new SubscriptionError(
`Rate limit exceeded. Please try again later.`,
Math.ceil((nextMonth.getTime() - now.getTime()) / 1000),
)
}
// Check interval limit
const intervalMs = black.intervalLength * 86400 * 1000
if (sub.subIntervalUsage && sub.timeSubIntervalUsageUpdated) {
const currentInterval = Math.floor(now.getTime() / intervalMs)
const usageInterval = Math.floor(sub.timeSubIntervalUsageUpdated.getTime() / intervalMs)
if (currentInterval === usageInterval && sub.subIntervalUsage >= centsToMicroCents(black.intervalLimit * 100)) {
const nextInterval = (currentInterval + 1) * intervalMs
throw new SubscriptionError(
`Rate limit exceeded. Please try again later.`,
Math.ceil((nextInterval - now.getTime()) / 1000),
)
}
}
return
}
// Validate pay as you go billing
const billing = authInfo.billing
if (!billing.paymentMethodID)
throw new CreditsError(
@@ -463,29 +525,25 @@ export async function handler(
billing.monthlyLimit &&
billing.monthlyUsage &&
billing.timeMonthlyUsageUpdated &&
billing.monthlyUsage >= centsToMicroCents(billing.monthlyLimit * 100)
) {
const dateYear = billing.timeMonthlyUsageUpdated.getUTCFullYear()
const dateMonth = billing.timeMonthlyUsageUpdated.getUTCMonth()
if (currentYear === dateYear && currentMonth === dateMonth)
throw new MonthlyLimitError(
`Your workspace has reached its monthly spending limit of $${billing.monthlyLimit}. Manage your limits here: https://opencode.ai/workspace/${authInfo.workspaceID}/billing`,
)
}
billing.monthlyUsage >= centsToMicroCents(billing.monthlyLimit * 100) &&
currentYear === billing.timeMonthlyUsageUpdated.getUTCFullYear() &&
currentMonth === billing.timeMonthlyUsageUpdated.getUTCMonth()
)
throw new MonthlyLimitError(
`Your workspace has reached its monthly spending limit of $${billing.monthlyLimit}. Manage your limits here: https://opencode.ai/workspace/${authInfo.workspaceID}/billing`,
)
if (
authInfo.user.monthlyLimit &&
authInfo.user.monthlyUsage &&
authInfo.user.timeMonthlyUsageUpdated &&
authInfo.user.monthlyUsage >= centsToMicroCents(authInfo.user.monthlyLimit * 100)
) {
const dateYear = authInfo.user.timeMonthlyUsageUpdated.getUTCFullYear()
const dateMonth = authInfo.user.timeMonthlyUsageUpdated.getUTCMonth()
if (currentYear === dateYear && currentMonth === dateMonth)
throw new UserLimitError(
`You have reached your monthly spending limit of $${authInfo.user.monthlyLimit}. Manage your limits here: https://opencode.ai/workspace/${authInfo.workspaceID}/members`,
)
}
authInfo.user.monthlyUsage >= centsToMicroCents(authInfo.user.monthlyLimit * 100) &&
currentYear === authInfo.user.timeMonthlyUsageUpdated.getUTCFullYear() &&
currentMonth === authInfo.user.timeMonthlyUsageUpdated.getUTCMonth()
)
throw new UserLimitError(
`You have reached your monthly spending limit of $${authInfo.user.monthlyLimit}. Manage your limits here: https://opencode.ai/workspace/${authInfo.workspaceID}/members`,
)
}
function validateModelSettings(authInfo: AuthInfo) {
@@ -560,7 +618,7 @@ export async function handler(
if (!authInfo) return
const cost = authInfo.isFree || authInfo.provider?.credentials ? 0 : centsToMicroCents(totalCostInCent)
const cost = authInfo.provider?.credentials ? 0 : centsToMicroCents(totalCostInCent)
await Database.use((db) =>
Promise.all([
db.insert(UsageTable).values({
@@ -576,36 +634,63 @@ export async function handler(
cacheWrite1hTokens,
cost,
keyID: authInfo.apiKeyId,
enrichment: authInfo.subscription ? { plan: "sub" } : undefined,
}),
db
.update(BillingTable)
.set({
balance: sql`${BillingTable.balance} - ${cost}`,
monthlyUsage: sql`
.update(KeyTable)
.set({ timeUsed: sql`now()` })
.where(and(eq(KeyTable.workspaceID, authInfo.workspaceID), eq(KeyTable.id, authInfo.apiKeyId))),
...(authInfo.subscription
? [
db
.update(UserTable)
.set({
subMonthlyUsage: sql`
CASE
WHEN MONTH(${UserTable.timeSubMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${UserTable.timeSubMonthlyUsageUpdated}) = YEAR(now()) THEN ${UserTable.subMonthlyUsage} + ${cost}
ELSE ${cost}
END
`,
timeSubMonthlyUsageUpdated: sql`now()`,
subIntervalUsage: sql`
CASE
WHEN FLOOR(UNIX_TIMESTAMP(${UserTable.timeSubIntervalUsageUpdated}) / (${BlackData.get().intervalLength} * 86400)) = FLOOR(UNIX_TIMESTAMP(now()) / (${BlackData.get().intervalLength} * 86400)) THEN ${UserTable.subIntervalUsage} + ${cost}
ELSE ${cost}
END
`,
timeSubIntervalUsageUpdated: sql`now()`,
})
.where(and(eq(UserTable.workspaceID, authInfo.workspaceID), eq(UserTable.id, authInfo.user.id))),
]
: [
db
.update(BillingTable)
.set({
balance: authInfo.isFree
? sql`${BillingTable.balance} - ${0}`
: sql`${BillingTable.balance} - ${cost}`,
monthlyUsage: sql`
CASE
WHEN MONTH(${BillingTable.timeMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${BillingTable.timeMonthlyUsageUpdated}) = YEAR(now()) THEN ${BillingTable.monthlyUsage} + ${cost}
ELSE ${cost}
END
`,
timeMonthlyUsageUpdated: sql`now()`,
})
.where(eq(BillingTable.workspaceID, authInfo.workspaceID)),
db
.update(UserTable)
.set({
monthlyUsage: sql`
timeMonthlyUsageUpdated: sql`now()`,
})
.where(eq(BillingTable.workspaceID, authInfo.workspaceID)),
db
.update(UserTable)
.set({
monthlyUsage: sql`
CASE
WHEN MONTH(${UserTable.timeMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${UserTable.timeMonthlyUsageUpdated}) = YEAR(now()) THEN ${UserTable.monthlyUsage} + ${cost}
ELSE ${cost}
END
`,
timeMonthlyUsageUpdated: sql`now()`,
})
.where(and(eq(UserTable.workspaceID, authInfo.workspaceID), eq(UserTable.id, authInfo.user.id))),
db
.update(KeyTable)
.set({ timeUsed: sql`now()` })
.where(and(eq(KeyTable.workspaceID, authInfo.workspaceID), eq(KeyTable.id, authInfo.apiKeyId))),
timeMonthlyUsageUpdated: sql`now()`,
})
.where(and(eq(UserTable.workspaceID, authInfo.workspaceID), eq(UserTable.id, authInfo.user.id))),
]),
]),
)
@@ -616,6 +701,7 @@ export async function handler(
if (!authInfo) return
if (authInfo.isFree) return
if (authInfo.provider?.credentials) return
if (authInfo.subscription) return
if (!costInfo) return

View File

@@ -0,0 +1,2 @@
ALTER TABLE `user` RENAME COLUMN `sub_recent_usage` TO `sub_interval_usage`;--> statement-breakpoint
ALTER TABLE `user` RENAME COLUMN `sub_time_recent_usage_updated` TO `sub_time_interval_usage_updated`;

View File

@@ -0,0 +1 @@
ALTER TABLE `usage` RENAME COLUMN `data` TO `enrichment`;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -302,6 +302,20 @@
"when": 1767744077346,
"tag": "0042_flat_nightmare",
"breakpoints": true
},
{
"idx": 43,
"version": "5",
"when": 1767752636118,
"tag": "0043_lame_calypso",
"breakpoints": true
},
{
"idx": 44,
"version": "5",
"when": 1767759322451,
"tag": "0044_tiny_captain_midlands",
"breakpoints": true
}
]
}
}

View File

@@ -32,6 +32,9 @@
"promote-models-to-dev": "script/promote-models.ts dev",
"promote-models-to-prod": "script/promote-models.ts production",
"pull-models-from-dev": "script/pull-models.ts dev",
"update-black": "script/update-black.ts",
"promote-black-to-dev": "script/promote-black.ts dev",
"promote-black-to-prod": "script/promote-black.ts production",
"typecheck": "tsgo --noEmit"
},
"devDependencies": {

View File

@@ -0,0 +1,22 @@
#!/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"))?.split("=")[1]
if (!value) throw new Error("ZEN_BLACK not found")
// validate value
BlackData.validate(JSON.parse(value))
// update the secret
await $`bun sst secret set ZEN_BLACK ${value} --stage ${stage}`

View File

@@ -0,0 +1,28 @@
#!/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 the line starting with "ZEN_BLACK"
const lines = secrets.split("\n")
const oldValue = lines.find((line) => line.startsWith("ZEN_BLACK"))?.split("=")[1]
if (!oldValue) throw new Error("ZEN_BLACK 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 ${newValue}`

View File

@@ -27,6 +27,7 @@ export namespace Billing {
tx
.select({
customerID: BillingTable.customerID,
subscriptionID: BillingTable.subscriptionID,
paymentMethodID: BillingTable.paymentMethodID,
paymentMethodType: BillingTable.paymentMethodType,
paymentMethodLast4: BillingTable.paymentMethodLast4,

View File

@@ -0,0 +1,20 @@
import { z } from "zod"
import { fn } from "./util/fn"
import { Resource } from "@opencode-ai/console-resource"
export namespace BlackData {
const Schema = z.object({
monthlyLimit: z.number().int(),
intervalLimit: z.number().int(),
intervalLength: z.number().int(),
})
export const validate = fn(Schema, (input) => {
return input
})
export const get = fn(z.void(), () => {
const json = JSON.parse(Resource.ZEN_BLACK.value)
return Schema.parse(json)
})
}

View File

@@ -55,7 +55,7 @@ export const UsageTable = mysqlTable(
cacheWrite1hTokens: int("cache_write_1h_tokens"),
cost: bigint("cost", { mode: "number" }).notNull(),
keyID: ulid("key_id"),
enrichment: json("data").$type<{
enrichment: json("enrichment").$type<{
plan: "sub"
}>(),
},

View File

@@ -20,9 +20,9 @@ export const UserTable = mysqlTable(
timeMonthlyUsageUpdated: utc("time_monthly_usage_updated"),
// subscription
timeSubscribed: utc("time_subscribed"),
subRecentUsage: bigint("sub_recent_usage", { mode: "number" }),
subIntervalUsage: bigint("sub_interval_usage", { mode: "number" }),
subMonthlyUsage: bigint("sub_monthly_usage", { mode: "number" }),
timeSubRecentUsageUpdated: utc("sub_time_recent_usage_updated"),
timeSubIntervalUsageUpdated: utc("sub_time_interval_usage_updated"),
timeSubMonthlyUsageUpdated: utc("sub_time_monthly_usage_updated"),
},
(table) => [

View File

@@ -98,6 +98,10 @@ declare module "sst" {
"type": "sst.cloudflare.StaticSite"
"url": string
}
"ZEN_BLACK": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS1": {
"type": "sst.sst.Secret"
"value": string

View File

@@ -98,6 +98,10 @@ declare module "sst" {
"type": "sst.cloudflare.StaticSite"
"url": string
}
"ZEN_BLACK": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS1": {
"type": "sst.sst.Secret"
"value": string

View File

@@ -98,6 +98,10 @@ declare module "sst" {
"type": "sst.cloudflare.StaticSite"
"url": string
}
"ZEN_BLACK": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS1": {
"type": "sst.sst.Secret"
"value": string

View File

@@ -98,6 +98,10 @@ declare module "sst" {
"type": "sst.cloudflare.StaticSite"
"url": string
}
"ZEN_BLACK": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS1": {
"type": "sst.sst.Secret"
"value": string

View File

@@ -98,6 +98,10 @@ declare module "sst" {
"type": "sst.cloudflare.StaticSite"
"url": string
}
"ZEN_BLACK": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS1": {
"type": "sst.sst.Secret"
"value": string

4
sst-env.d.ts vendored
View File

@@ -124,6 +124,10 @@ declare module "sst" {
"type": "sst.cloudflare.StaticSite"
"url": string
}
"ZEN_BLACK": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS1": {
"type": "sst.sst.Secret"
"value": string