Skip to content

Commit 24d9423

Browse files
committed
zen: use balance after rate limited
1 parent 65c236c commit 24d9423

5 files changed

Lines changed: 180 additions & 50 deletions

File tree

packages/console/app/src/routes/workspace/[id]/billing/black-section.module.css

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,4 +59,84 @@
5959
font-size: var(--font-size-sm);
6060
color: var(--color-text-muted);
6161
}
62+
63+
[data-slot="setting-row"] {
64+
display: flex;
65+
align-items: center;
66+
justify-content: space-between;
67+
gap: var(--space-3);
68+
margin-top: var(--space-4);
69+
70+
p {
71+
font-size: var(--font-size-sm);
72+
line-height: 1.5;
73+
color: var(--color-text-secondary);
74+
margin: 0;
75+
}
76+
}
77+
78+
[data-slot="toggle-label"] {
79+
position: relative;
80+
display: inline-block;
81+
width: 2.5rem;
82+
height: 1.5rem;
83+
cursor: pointer;
84+
flex-shrink: 0;
85+
86+
input {
87+
opacity: 0;
88+
width: 0;
89+
height: 0;
90+
}
91+
92+
span {
93+
position: absolute;
94+
inset: 0;
95+
background-color: #ccc;
96+
border: 1px solid #bbb;
97+
border-radius: 1.5rem;
98+
transition: all 0.3s ease;
99+
cursor: pointer;
100+
101+
&::before {
102+
content: "";
103+
position: absolute;
104+
top: 50%;
105+
left: 0.125rem;
106+
width: 1.25rem;
107+
height: 1.25rem;
108+
background-color: white;
109+
border: 1px solid #ddd;
110+
border-radius: 50%;
111+
transform: translateY(-50%);
112+
transition: all 0.3s ease;
113+
}
114+
}
115+
116+
input:checked + span {
117+
background-color: #21ad0e;
118+
border-color: #148605;
119+
120+
&::before {
121+
transform: translateX(1rem) translateY(-50%);
122+
}
123+
}
124+
125+
&:hover span {
126+
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.2);
127+
}
128+
129+
input:checked:hover + span {
130+
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.3);
131+
}
132+
133+
&:has(input:disabled) {
134+
cursor: not-allowed;
135+
}
136+
137+
input:disabled + span {
138+
opacity: 0.5;
139+
cursor: not-allowed;
140+
}
141+
}
62142
}

packages/console/app/src/routes/workspace/[id]/billing/black-section.tsx

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { action, useParams, useAction, useSubmission, json, query, createAsync }
22
import { createStore } from "solid-js/store"
33
import { Show } from "solid-js"
44
import { Billing } from "@opencode-ai/console-core/billing.js"
5-
import { Database, eq, and, isNull } from "@opencode-ai/console-core/drizzle/index.js"
5+
import { Database, eq, and, isNull, sql } from "@opencode-ai/console-core/drizzle/index.js"
66
import { BillingTable, SubscriptionTable } from "@opencode-ai/console-core/schema/billing.sql.js"
77
import { Actor } from "@opencode-ai/console-core/actor.js"
88
import { Black } from "@opencode-ai/console-core/black.js"
@@ -32,6 +32,7 @@ const querySubscription = query(async (workspaceID: string) => {
3232

3333
return {
3434
plan: row.subscription.plan,
35+
useBalance: row.subscription.useBalance ?? false,
3536
rollingUsage: Black.analyzeRollingUsage({
3637
plan: row.subscription.plan,
3738
usage: row.rollingUsage ?? 0,
@@ -107,6 +108,30 @@ const createSessionUrl = action(async (workspaceID: string, returnUrl: string) =
107108
)
108109
}, "sessionUrl")
109110

111+
const setUseBalance = action(async (form: FormData) => {
112+
"use server"
113+
const workspaceID = form.get("workspaceID")?.toString()
114+
if (!workspaceID) return { error: "Workspace ID is required" }
115+
const useBalance = form.get("useBalance")?.toString() === "true"
116+
117+
return json(
118+
await withActor(async () => {
119+
await Database.use((tx) =>
120+
tx
121+
.update(BillingTable)
122+
.set({
123+
subscription: useBalance
124+
? sql`JSON_SET(subscription, '$.useBalance', true)`
125+
: sql`JSON_REMOVE(subscription, '$.useBalance')`,
126+
})
127+
.where(eq(BillingTable.workspaceID, workspaceID)),
128+
)
129+
return { error: undefined }
130+
}, workspaceID).catch((e) => ({ error: e.message as string })),
131+
{ revalidate: [queryBillingInfo.key, querySubscription.key] },
132+
)
133+
}, "setUseBalance")
134+
110135
export function BlackSection() {
111136
const params = useParams()
112137
const billing = createAsync(() => queryBillingInfo(params.id!))
@@ -117,6 +142,7 @@ export function BlackSection() {
117142
const cancelSubmission = useSubmission(cancelWaitlist)
118143
const enrollAction = useAction(enroll)
119144
const enrollSubmission = useSubmission(enroll)
145+
const useBalanceSubmission = useSubmission(setUseBalance)
120146
const [store, setStore] = createStore({
121147
sessionRedirecting: false,
122148
cancelled: false,
@@ -185,6 +211,20 @@ export function BlackSection() {
185211
<span data-slot="reset-time">Resets in {formatResetTime(sub().weeklyUsage.resetInSec)}</span>
186212
</div>
187213
</div>
214+
<form action={setUseBalance} method="post" data-slot="setting-row">
215+
<p>Use your available balance after reaching the usage limits</p>
216+
<input type="hidden" name="workspaceID" value={params.id} />
217+
<input type="hidden" name="useBalance" value={sub().useBalance ? "false" : "true"} />
218+
<label data-slot="toggle-label">
219+
<input
220+
type="checkbox"
221+
checked={sub().useBalance}
222+
disabled={useBalanceSubmission.pending}
223+
onChange={(e) => e.currentTarget.form?.requestSubmit()}
224+
/>
225+
<span></span>
226+
</label>
227+
</form>
188228
</section>
189229
)}
190230
</Show>

packages/console/app/src/routes/workspace/common.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ export const queryBillingInfo = query(async (workspaceID: string) => {
110110
timeMonthlyUsageUpdated: billing.timeMonthlyUsageUpdated,
111111
reloadError: billing.reloadError,
112112
timeReloadError: billing.timeReloadError,
113+
subscription: billing.subscription,
113114
subscriptionID: billing.subscriptionID,
114115
subscriptionPlan: billing.subscriptionPlan,
115116
timeSubscriptionBooked: billing.timeSubscriptionBooked,

packages/console/app/src/routes/zen/util/handler.ts

Lines changed: 56 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ export async function handler(
8484
const stickyTracker = createStickyTracker(modelInfo.stickyProvider, sessionId)
8585
const stickyProvider = await stickyTracker?.get()
8686
const authInfo = await authenticate(modelInfo)
87+
const billingSource = validateBilling(authInfo, modelInfo)
8788

8889
const retriableRequest = async (retry: RetryOptions = { excludeProviders: [], retryCount: 0 }) => {
8990
const providerInfo = selectProvider(
@@ -96,7 +97,6 @@ export async function handler(
9697
retry,
9798
stickyProvider,
9899
)
99-
validateBilling(authInfo, modelInfo)
100100
validateModelSettings(authInfo)
101101
updateProviderKey(authInfo, providerInfo)
102102
logger.metric({ provider: providerInfo.id })
@@ -183,7 +183,7 @@ export async function handler(
183183
const tokensInfo = providerInfo.normalizeUsage(json.usage)
184184
await trialLimiter?.track(tokensInfo)
185185
await rateLimiter?.track()
186-
const costInfo = await trackUsage(authInfo, modelInfo, providerInfo, tokensInfo)
186+
const costInfo = await trackUsage(authInfo, modelInfo, providerInfo, billingSource, tokensInfo)
187187
await reload(authInfo, costInfo)
188188
return new Response(body, {
189189
status: resStatus,
@@ -219,7 +219,7 @@ export async function handler(
219219
if (usage) {
220220
const tokensInfo = providerInfo.normalizeUsage(usage)
221221
await trialLimiter?.track(tokensInfo)
222-
const costInfo = await trackUsage(authInfo, modelInfo, providerInfo, tokensInfo)
222+
const costInfo = await trackUsage(authInfo, modelInfo, providerInfo, billingSource, tokensInfo)
223223
await reload(authInfo, costInfo)
224224
}
225225
c.close()
@@ -484,54 +484,58 @@ export async function handler(
484484
}
485485

486486
function validateBilling(authInfo: AuthInfo, modelInfo: ModelInfo) {
487-
if (!authInfo) return
488-
if (authInfo.provider?.credentials) return
489-
if (authInfo.isFree) return
490-
if (modelInfo.allowAnonymous) return
487+
if (!authInfo) return "anonymous"
488+
if (authInfo.provider?.credentials) return "free"
489+
if (authInfo.isFree) return "free"
490+
if (modelInfo.allowAnonymous) return "free"
491491

492492
// Validate subscription billing
493493
if (authInfo.billing.subscription && authInfo.subscription) {
494-
const sub = authInfo.subscription
495-
const plan = authInfo.billing.subscription.plan
496-
497-
const formatRetryTime = (seconds: number) => {
498-
const days = Math.floor(seconds / 86400)
499-
if (days >= 1) return `${days} day${days > 1 ? "s" : ""}`
500-
const hours = Math.floor(seconds / 3600)
501-
const minutes = Math.ceil((seconds % 3600) / 60)
502-
if (hours >= 1) return `${hours}hr ${minutes}min`
503-
return `${minutes}min`
504-
}
494+
try {
495+
const sub = authInfo.subscription
496+
const plan = authInfo.billing.subscription.plan
497+
498+
const formatRetryTime = (seconds: number) => {
499+
const days = Math.floor(seconds / 86400)
500+
if (days >= 1) return `${days} day${days > 1 ? "s" : ""}`
501+
const hours = Math.floor(seconds / 3600)
502+
const minutes = Math.ceil((seconds % 3600) / 60)
503+
if (hours >= 1) return `${hours}hr ${minutes}min`
504+
return `${minutes}min`
505+
}
505506

506-
// Check weekly limit
507-
if (sub.fixedUsage && sub.timeFixedUpdated) {
508-
const result = Black.analyzeWeeklyUsage({
509-
plan,
510-
usage: sub.fixedUsage,
511-
timeUpdated: sub.timeFixedUpdated,
512-
})
513-
if (result.status === "rate-limited")
514-
throw new SubscriptionError(
515-
`Subscription quota exceeded. Retry in ${formatRetryTime(result.resetInSec)}.`,
516-
result.resetInSec,
517-
)
518-
}
507+
// Check weekly limit
508+
if (sub.fixedUsage && sub.timeFixedUpdated) {
509+
const result = Black.analyzeWeeklyUsage({
510+
plan,
511+
usage: sub.fixedUsage,
512+
timeUpdated: sub.timeFixedUpdated,
513+
})
514+
if (result.status === "rate-limited")
515+
throw new SubscriptionError(
516+
`Subscription quota exceeded. Retry in ${formatRetryTime(result.resetInSec)}.`,
517+
result.resetInSec,
518+
)
519+
}
519520

520-
// Check rolling limit
521-
if (sub.rollingUsage && sub.timeRollingUpdated) {
522-
const result = Black.analyzeRollingUsage({
523-
plan,
524-
usage: sub.rollingUsage,
525-
timeUpdated: sub.timeRollingUpdated,
526-
})
527-
if (result.status === "rate-limited")
528-
throw new SubscriptionError(
529-
`Subscription quota exceeded. Retry in ${formatRetryTime(result.resetInSec)}.`,
530-
result.resetInSec,
531-
)
532-
}
521+
// Check rolling limit
522+
if (sub.rollingUsage && sub.timeRollingUpdated) {
523+
const result = Black.analyzeRollingUsage({
524+
plan,
525+
usage: sub.rollingUsage,
526+
timeUpdated: sub.timeRollingUpdated,
527+
})
528+
if (result.status === "rate-limited")
529+
throw new SubscriptionError(
530+
`Subscription quota exceeded. Retry in ${formatRetryTime(result.resetInSec)}.`,
531+
result.resetInSec,
532+
)
533+
}
533534

534-
return
535+
return "subscription"
536+
} catch(e) {
537+
if (!authInfo.billing.subscription.useBalance) throw e
538+
}
535539
}
536540

537541
// Validate pay as you go billing
@@ -571,6 +575,8 @@ export async function handler(
571575
throw new UserLimitError(
572576
`You have reached your monthly spending limit of $${authInfo.user.monthlyLimit}. Manage your limits here: https://opencode.ai/workspace/${authInfo.workspaceID}/members`,
573577
)
578+
579+
return "balance"
574580
}
575581

576582
function validateModelSettings(authInfo: AuthInfo) {
@@ -587,6 +593,7 @@ export async function handler(
587593
authInfo: AuthInfo,
588594
modelInfo: ModelInfo,
589595
providerInfo: ProviderInfo,
596+
billingSource: ReturnType<typeof validateBilling>,
590597
usageInfo: UsageInfo,
591598
) {
592599
const { inputTokens, outputTokens, reasoningTokens, cacheReadTokens, cacheWrite5mTokens, cacheWrite1hTokens } =
@@ -643,7 +650,8 @@ export async function handler(
643650
"cost.total": Math.round(totalCostInCent),
644651
})
645652

646-
if (!authInfo) return
653+
if (billingSource === "anonymous") return
654+
authInfo = authInfo!
647655

648656
const cost = authInfo.provider?.credentials ? 0 : centsToMicroCents(totalCostInCent)
649657
await Database.use((db) =>
@@ -661,13 +669,13 @@ export async function handler(
661669
cacheWrite1hTokens,
662670
cost,
663671
keyID: authInfo.apiKeyId,
664-
enrichment: authInfo.subscription ? { plan: "sub" } : undefined,
672+
enrichment: billingSource === "subscription" ? { plan: "sub" } : undefined,
665673
}),
666674
db
667675
.update(KeyTable)
668676
.set({ timeUsed: sql`now()` })
669677
.where(and(eq(KeyTable.workspaceID, authInfo.workspaceID), eq(KeyTable.id, authInfo.apiKeyId))),
670-
...(authInfo.subscription
678+
...(billingSource === "subscription"
671679
? (() => {
672680
const plan = authInfo.billing.subscription!.plan
673681
const black = BlackData.getLimits({ plan })

packages/console/core/src/schema/billing.sql.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,10 @@ export const BillingTable = mysqlTable(
2424
timeReloadLockedTill: utc("time_reload_locked_till"),
2525
subscription: json("subscription").$type<{
2626
status: "subscribed"
27-
coupon?: string
2827
seats: number
2928
plan: "20" | "100" | "200"
29+
useBalance?: boolean
30+
coupon?: string
3031
}>(),
3132
subscriptionID: varchar("subscription_id", { length: 28 }),
3233
subscriptionPlan: mysqlEnum("subscription_plan", SubscriptionPlan),

0 commit comments

Comments
 (0)