summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorFrank <[email protected]>2026-01-14 21:20:23 -0500
committerFrank <[email protected]>2026-01-14 21:20:26 -0500
commite03932e586e89cc30015e6a5006ed5e83881a05c (patch)
tree5655f0c063b717399a71a07625a8d6db8a942a5f
parent6b019a125a93a9816f9ddec17dc04d2c6e9b4257 (diff)
downloadopencode-e03932e586e89cc30015e6a5006ed5e83881a05c.tar.gz
opencode-e03932e586e89cc30015e6a5006ed5e83881a05c.zip
zen: black usage
-rw-r--r--packages/console/app/src/routes/workspace/[id]/billing/black-section.module.css56
-rw-r--r--packages/console/app/src/routes/workspace/[id]/billing/black-section.tsx77
-rw-r--r--packages/console/app/src/routes/workspace/common.tsx14
-rw-r--r--packages/console/app/src/routes/zen/util/handler.ts29
-rw-r--r--packages/console/core/src/billing.ts17
-rw-r--r--packages/console/core/src/black.ts72
6 files changed, 231 insertions, 34 deletions
diff --git a/packages/console/app/src/routes/workspace/[id]/billing/black-section.module.css b/packages/console/app/src/routes/workspace/[id]/billing/black-section.module.css
index c189f0d64..0147afecc 100644
--- a/packages/console/app/src/routes/workspace/[id]/billing/black-section.module.css
+++ b/packages/console/app/src/routes/workspace/[id]/billing/black-section.module.css
@@ -5,4 +5,58 @@
align-items: center;
gap: var(--space-4);
}
-}
+
+ [data-slot="usage"] {
+ display: flex;
+ gap: var(--space-6);
+ margin-top: var(--space-4);
+
+ @media (max-width: 40rem) {
+ flex-direction: column;
+ gap: var(--space-4);
+ }
+ }
+
+ [data-slot="usage-item"] {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ }
+
+ [data-slot="usage-header"] {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ }
+
+ [data-slot="usage-label"] {
+ font-size: var(--font-size-md);
+ font-weight: 500;
+ color: var(--color-text);
+ }
+
+ [data-slot="usage-value"] {
+ font-size: var(--font-size-sm);
+ color: var(--color-text-muted);
+ }
+
+ [data-slot="progress"] {
+ height: 8px;
+ background-color: var(--color-bg-surface);
+ border-radius: var(--border-radius-sm);
+ overflow: hidden;
+ }
+
+ [data-slot="progress-bar"] {
+ height: 100%;
+ background-color: var(--color-accent);
+ border-radius: var(--border-radius-sm);
+ transition: width 0.3s ease;
+ }
+
+ [data-slot="reset-time"] {
+ font-size: var(--font-size-sm);
+ color: var(--color-text-muted);
+ }
+} \ No newline at end of file
diff --git a/packages/console/app/src/routes/workspace/[id]/billing/black-section.tsx b/packages/console/app/src/routes/workspace/[id]/billing/black-section.tsx
index 2eece1b62..beb5adbfc 100644
--- a/packages/console/app/src/routes/workspace/[id]/billing/black-section.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/billing/black-section.tsx
@@ -1,10 +1,58 @@
-import { action, useParams, useAction, useSubmission, json } from "@solidjs/router"
+import { action, useParams, useAction, useSubmission, json, query, createAsync } from "@solidjs/router"
import { createStore } from "solid-js/store"
+import { Show } from "solid-js"
import { Billing } from "@opencode-ai/console-core/billing.js"
+import { Database, eq, and, isNull } from "@opencode-ai/console-core/drizzle/index.js"
+import { SubscriptionTable } from "@opencode-ai/console-core/schema/billing.sql.js"
+import { Actor } from "@opencode-ai/console-core/actor.js"
+import { Black } from "@opencode-ai/console-core/black.js"
import { withActor } from "~/context/auth.withActor"
import { queryBillingInfo } from "../../common"
import styles from "./black-section.module.css"
+const querySubscription = query(async (workspaceID: string) => {
+ "use server"
+ return withActor(async () => {
+ const row = await Database.use((tx) =>
+ tx
+ .select({
+ rollingUsage: SubscriptionTable.rollingUsage,
+ fixedUsage: SubscriptionTable.fixedUsage,
+ timeRollingUpdated: SubscriptionTable.timeRollingUpdated,
+ timeFixedUpdated: SubscriptionTable.timeFixedUpdated,
+ })
+ .from(SubscriptionTable)
+ .where(and(eq(SubscriptionTable.workspaceID, Actor.workspace()), isNull(SubscriptionTable.timeDeleted)))
+ .then((r) => r[0]),
+ )
+ if (!row) return null
+
+ return {
+ rollingUsage: Black.analyzeRollingUsage({
+ usage: row.rollingUsage ?? 0,
+ timeUpdated: row.timeRollingUpdated ?? new Date(),
+ }),
+ weeklyUsage: Black.analyzeWeeklyUsage({
+ usage: row.fixedUsage ?? 0,
+ timeUpdated: row.timeFixedUpdated ?? new Date(),
+ }),
+ }
+ }, workspaceID)
+}, "subscription.get")
+
+function formatResetTime(seconds: number) {
+ const days = Math.floor(seconds / 86400)
+ if (days >= 1) {
+ const hours = Math.floor((seconds % 86400) / 3600)
+ return `${days} ${days === 1 ? "day" : "days"} ${hours} ${hours === 1 ? "hour" : "hours"}`
+ }
+ const hours = Math.floor(seconds / 3600)
+ const minutes = Math.floor((seconds % 3600) / 60)
+ if (hours >= 1) return `${hours} ${hours === 1 ? "hour" : "hours"} ${minutes} ${minutes === 1 ? "minute" : "minutes"}`
+ if (minutes === 0) return "a few seconds"
+ return `${minutes} ${minutes === 1 ? "minute" : "minutes"}`
+}
+
const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => {
"use server"
return json(
@@ -26,6 +74,7 @@ export function BlackSection() {
const params = useParams()
const sessionAction = useAction(createSessionUrl)
const sessionSubmission = useSubmission(createSessionUrl)
+ const subscription = createAsync(() => querySubscription(params.id!))
const [store, setStore] = createStore({
sessionRedirecting: false,
})
@@ -53,6 +102,32 @@ export function BlackSection() {
</button>
</div>
</div>
+ <Show when={subscription()}>
+ {(sub) => (
+ <div data-slot="usage">
+ <div data-slot="usage-item">
+ <div data-slot="usage-header">
+ <span data-slot="usage-label">5-hour Usage</span>
+ <span data-slot="usage-value">{sub().rollingUsage.usagePercent}%</span>
+ </div>
+ <div data-slot="progress">
+ <div data-slot="progress-bar" style={{ width: `${sub().rollingUsage.usagePercent}%` }} />
+ </div>
+ <span data-slot="reset-time">Resets in {formatResetTime(sub().rollingUsage.resetInSec)}</span>
+ </div>
+ <div data-slot="usage-item">
+ <div data-slot="usage-header">
+ <span data-slot="usage-label">Weekly Usage</span>
+ <span data-slot="usage-value">{sub().weeklyUsage.usagePercent}%</span>
+ </div>
+ <div data-slot="progress">
+ <div data-slot="progress-bar" style={{ width: `${sub().weeklyUsage.usagePercent}%` }} />
+ </div>
+ <span data-slot="reset-time">Resets in {formatResetTime(sub().weeklyUsage.resetInSec)}</span>
+ </div>
+ </div>
+ )}
+ </Show>
</section>
)
}
diff --git a/packages/console/app/src/routes/workspace/common.tsx b/packages/console/app/src/routes/workspace/common.tsx
index a6eaaeb1e..d97bf9e60 100644
--- a/packages/console/app/src/routes/workspace/common.tsx
+++ b/packages/console/app/src/routes/workspace/common.tsx
@@ -3,7 +3,6 @@ import { Actor } from "@opencode-ai/console-core/actor.js"
import { action, json, query } from "@solidjs/router"
import { withActor } from "~/context/auth.withActor"
import { Billing } from "@opencode-ai/console-core/billing.js"
-import { User } from "@opencode-ai/console-core/user.js"
import { and, Database, desc, eq, isNull } from "@opencode-ai/console-core/drizzle/index.js"
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
@@ -96,11 +95,22 @@ export const queryBillingInfo = query(async (workspaceID: string) => {
return withActor(async () => {
const billing = await Billing.get()
return {
- ...billing,
+ customerID: billing.customerID,
+ paymentMethodID: billing.paymentMethodID,
+ paymentMethodType: billing.paymentMethodType,
+ paymentMethodLast4: billing.paymentMethodLast4,
+ balance: billing.balance,
+ reload: billing.reload,
reloadAmount: billing.reloadAmount ?? Billing.RELOAD_AMOUNT,
reloadAmountMin: Billing.RELOAD_AMOUNT_MIN,
reloadTrigger: billing.reloadTrigger ?? Billing.RELOAD_TRIGGER,
reloadTriggerMin: Billing.RELOAD_TRIGGER_MIN,
+ monthlyLimit: billing.monthlyLimit,
+ monthlyUsage: billing.monthlyUsage,
+ timeMonthlyUsageUpdated: billing.timeMonthlyUsageUpdated,
+ reloadError: billing.reloadError,
+ timeReloadError: billing.timeReloadError,
+ subscriptionID: billing.subscriptionID,
}
}, workspaceID)
}, "billing.get")
diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts
index 56efe451c..2ecc4220a 100644
--- a/packages/console/app/src/routes/zen/util/handler.ts
+++ b/packages/console/app/src/routes/zen/util/handler.ts
@@ -9,7 +9,7 @@ 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 { Black, 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"
@@ -495,27 +495,28 @@ export async function handler(
// Check weekly limit
if (sub.fixedUsage && sub.timeFixedUpdated) {
- const week = getWeekBounds(now)
- if (sub.timeFixedUpdated >= week.start && sub.fixedUsage >= centsToMicroCents(black.fixedLimit * 100)) {
- const retryAfter = Math.ceil((week.end.getTime() - now.getTime()) / 1000)
+ const result = Black.analyzeWeeklyUsage({
+ usage: sub.fixedUsage,
+ timeUpdated: sub.timeFixedUpdated,
+ })
+ if (result.status === "rate-limited")
throw new SubscriptionError(
- `Subscription quota exceeded. Retry in ${formatRetryTime(retryAfter)}.`,
- retryAfter,
+ `Subscription quota exceeded. Retry in ${formatRetryTime(result.resetInSec)}.`,
+ result.resetInSec,
)
- }
}
// Check rolling limit
if (sub.rollingUsage && sub.timeRollingUpdated) {
- const rollingWindowMs = black.rollingWindow * 3600 * 1000
- const windowStart = new Date(now.getTime() - rollingWindowMs)
- if (sub.timeRollingUpdated >= windowStart && sub.rollingUsage >= centsToMicroCents(black.rollingLimit * 100)) {
- const retryAfter = Math.ceil((sub.timeRollingUpdated.getTime() + rollingWindowMs - now.getTime()) / 1000)
+ const result = Black.analyzeRollingUsage({
+ usage: sub.rollingUsage,
+ timeUpdated: sub.timeRollingUpdated,
+ })
+ if (result.status === "rate-limited")
throw new SubscriptionError(
- `Subscription quota exceeded. Retry in ${formatRetryTime(retryAfter)}.`,
- retryAfter,
+ `Subscription quota exceeded. Retry in ${formatRetryTime(result.resetInSec)}.`,
+ result.resetInSec,
)
- }
}
return
diff --git a/packages/console/core/src/billing.ts b/packages/console/core/src/billing.ts
index 181055c4e..f052e6fc6 100644
--- a/packages/console/core/src/billing.ts
+++ b/packages/console/core/src/billing.ts
@@ -25,22 +25,7 @@ export namespace Billing {
export const get = async () => {
return Database.use(async (tx) =>
tx
- .select({
- customerID: BillingTable.customerID,
- subscriptionID: BillingTable.subscriptionID,
- paymentMethodID: BillingTable.paymentMethodID,
- paymentMethodType: BillingTable.paymentMethodType,
- paymentMethodLast4: BillingTable.paymentMethodLast4,
- balance: BillingTable.balance,
- reload: BillingTable.reload,
- reloadAmount: BillingTable.reloadAmount,
- reloadTrigger: BillingTable.reloadTrigger,
- monthlyLimit: BillingTable.monthlyLimit,
- monthlyUsage: BillingTable.monthlyUsage,
- timeMonthlyUsageUpdated: BillingTable.timeMonthlyUsageUpdated,
- reloadError: BillingTable.reloadError,
- timeReloadError: BillingTable.timeReloadError,
- })
+ .select()
.from(BillingTable)
.where(eq(BillingTable.workspaceID, Actor.workspace()))
.then((r) => r[0]),
diff --git a/packages/console/core/src/black.ts b/packages/console/core/src/black.ts
index 0ecbc94a4..753d25808 100644
--- a/packages/console/core/src/black.ts
+++ b/packages/console/core/src/black.ts
@@ -1,6 +1,8 @@
import { z } from "zod"
import { fn } from "./util/fn"
import { Resource } from "@opencode-ai/console-resource"
+import { centsToMicroCents } from "./util/price"
+import { getWeekBounds } from "./util/date"
export namespace BlackData {
const Schema = z.object({
@@ -18,3 +20,73 @@ export namespace BlackData {
return Schema.parse(json)
})
}
+
+export namespace Black {
+ export const analyzeRollingUsage = fn(
+ z.object({
+ usage: z.number().int(),
+ timeUpdated: z.date(),
+ }),
+ ({ usage, timeUpdated }) => {
+ const now = new Date()
+ const black = BlackData.get()
+ const rollingWindowMs = black.rollingWindow * 3600 * 1000
+ const rollingLimitInMicroCents = centsToMicroCents(black.rollingLimit * 100)
+ const windowStart = new Date(now.getTime() - rollingWindowMs)
+ if (timeUpdated < windowStart) {
+ return {
+ status: "ok" as const,
+ resetInSec: black.rollingWindow * 3600,
+ usagePercent: 0,
+ }
+ }
+
+ const windowEnd = new Date(timeUpdated.getTime() + rollingWindowMs)
+ if (usage < rollingLimitInMicroCents) {
+ return {
+ status: "ok" as const,
+ resetInSec: Math.ceil((windowEnd.getTime() - now.getTime()) / 1000),
+ usagePercent: Math.ceil(Math.min(100, (usage / rollingLimitInMicroCents) * 100)),
+ }
+ }
+ return {
+ status: "rate-limited" as const,
+ resetInSec: Math.ceil((windowEnd.getTime() - now.getTime()) / 1000),
+ usagePercent: 100,
+ }
+ },
+ )
+
+ export const analyzeWeeklyUsage = fn(
+ z.object({
+ usage: z.number().int(),
+ timeUpdated: z.date(),
+ }),
+ ({ usage, timeUpdated }) => {
+ const black = BlackData.get()
+ const now = new Date()
+ const week = getWeekBounds(now)
+ const fixedLimitInMicroCents = centsToMicroCents(black.fixedLimit * 100)
+ if (timeUpdated < week.start) {
+ return {
+ status: "ok" as const,
+ resetInSec: Math.ceil((week.end.getTime() - now.getTime()) / 1000),
+ usagePercent: 0,
+ }
+ }
+ if (usage < fixedLimitInMicroCents) {
+ return {
+ status: "ok" as const,
+ resetInSec: Math.ceil((week.end.getTime() - now.getTime()) / 1000),
+ usagePercent: Math.ceil(Math.min(100, (usage / fixedLimitInMicroCents) * 100)),
+ }
+ }
+
+ return {
+ status: "rate-limited" as const,
+ resetInSec: Math.ceil((week.end.getTime() - now.getTime()) / 1000),
+ usagePercent: 100,
+ }
+ },
+ )
+}