Skip to content

Commit 7efade2

Browse files
committed
zen: monitor budget
1 parent 231f494 commit 7efade2

3 files changed

Lines changed: 66 additions & 0 deletions

File tree

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import { i18n, type Key } from "~/i18n"
4747
import { localeFromRequest } from "~/lib/language"
4848
import { createModelTpmLimiter } from "./modelTpmLimiter"
4949
import { createModelTpsLimiter } from "./modelTpsLimiter"
50+
import { createProviderBudgetTracker } from "./providerBudgetTracker"
5051
import { accumulateUsage, HOT_WORKSPACES } from "./usageBatcher"
5152

5253
type ZenData = Awaited<ReturnType<typeof ZenData.list>>
@@ -132,6 +133,10 @@ export async function handler(
132133
const modelTpmLimits = await modelTpmLimiter?.check()
133134
const modelTpsLimiter = createModelTpsLimiter(modelInfo.providers)
134135
const modelTpsLimits = await modelTpsLimiter?.check()
136+
const providerBudgetTracker = createProviderBudgetTracker(
137+
modelInfo.providers.map((provider) => ({ ...zenData.providers[provider.id], ...provider })),
138+
)
139+
const providerBudgetUsage = await providerBudgetTracker?.check()
135140

136141
const retriableRequest = async (retry: RetryOptions = { excludeProviders: [], retryCount: 0 }) => {
137142
const providerInfo = selectProvider(
@@ -145,12 +150,16 @@ export async function handler(
145150
stickyProvider,
146151
modelTpmLimits,
147152
modelTpsLimits,
153+
providerBudgetUsage,
148154
)
149155
validateModelSettings(billingSource, authInfo)
150156
updateProviderKey(authInfo, providerInfo)
151157
logger.metric({
152158
provider: providerInfo.id,
153159
"provider.model": providerInfo.model,
160+
...(providerBudgetUsage?.[providerInfo.id]
161+
? { "provider.budget_usage": providerBudgetUsage?.[providerInfo.id] }
162+
: {}),
154163
})
155164

156165
const startTimestamp = Date.now()
@@ -257,6 +266,7 @@ export async function handler(
257266
const costInfo = calculateCost(modelInfo, usageInfo)
258267
await trialLimiter?.track(usageInfo)
259268
await modelTpmLimiter?.track(providerInfo.id, providerInfo.model, usageInfo)
269+
await providerBudgetTracker?.track(providerInfo.id, costInfo.totalCostInCent)
260270
await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo)
261271
await reload(billingSource, authInfo, costInfo)
262272
json.cost = calculateOccurredCost(billingSource, costInfo)
@@ -317,6 +327,7 @@ export async function handler(
317327
timestampLastByte,
318328
usageInfo,
319329
)
330+
await providerBudgetTracker?.track(providerInfo.id, costInfo.totalCostInCent)
320331
await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo)
321332
await reload(billingSource, authInfo, costInfo)
322333
const cost = calculateOccurredCost(billingSource, costInfo)
@@ -483,6 +494,7 @@ export async function handler(
483494
stickyProviderId: string | undefined,
484495
modelTpmLimits: Record<string, number> | undefined,
485496
modelTpsLimits: Record<string, { qualify: number; unqualify: number }> | undefined,
497+
providerBudgetUsage: Record<string, number> | undefined,
486498
) {
487499
const modelProvider = (() => {
488500
// Byok is top priority b/c if user set their own API key, we should use it
@@ -505,6 +517,12 @@ export async function handler(
505517
const providers = allProviders
506518
.filter((provider) => provider.weight !== 0)
507519
.filter((provider) => !retry.excludeProviders.includes(provider.id))
520+
.filter((provider) => {
521+
if (provider.budgetMode !== "fill") return true
522+
const budget = zenData.providers[provider.id]?.budget
523+
if (budget === undefined) return false
524+
return (providerBudgetUsage?.[provider.id] ?? 0) < centsToMicroCents(budget * 100)
525+
})
508526
.filter((provider) => {
509527
if (!provider.tpmLimit) return true
510528
const usage = modelTpmLimits?.[`${provider.id}/${provider.model}`] ?? 0
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { centsToMicroCents } from "@opencode-ai/console-core/util/price.js"
2+
import { buildRateLimitKey, getRedis } from "./redis"
3+
4+
export function createProviderBudgetTracker(
5+
providers: {
6+
id: string
7+
budget?: number
8+
budgetContribution?: number
9+
budgetMode?: "always" | "fill"
10+
}[],
11+
) {
12+
const tracked = providers.filter(
13+
(provider) => provider.budget !== undefined && provider.budgetContribution !== undefined,
14+
)
15+
if (tracked.length === 0) return undefined
16+
17+
const interval = new Date()
18+
.toISOString()
19+
.replace(/[^0-9]/g, "")
20+
.substring(0, 12)
21+
const redis = getRedis()
22+
const keys = Object.fromEntries(
23+
tracked.map((provider) => [provider.id, buildRateLimitKey("provider-budget", provider.id, interval)]),
24+
)
25+
26+
return {
27+
check: async () => {
28+
const ids = tracked.filter((provider) => provider.budgetMode === "fill").map((provider) => provider.id)
29+
if (ids.length === 0) return {}
30+
const values = await redis.mget<(string | number | null)[]>(ids.map((id) => keys[id]))
31+
return Object.fromEntries(ids.map((id, index) => [id, Number(values[index] ?? 0)]))
32+
},
33+
track: async (provider: string, costInCent: number) => {
34+
const config = tracked.find((item) => item.id === provider)
35+
if (!config) return
36+
if (config.budgetContribution === undefined) return
37+
const cost = centsToMicroCents(costInCent * config.budgetContribution)
38+
if (cost <= 0) return
39+
const pipeline = redis.pipeline()
40+
pipeline.incrby(keys[provider], cost)
41+
pipeline.expire(keys[provider], 120)
42+
await pipeline.exec()
43+
},
44+
}
45+
}

packages/console/core/src/model.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ export namespace ZenData {
3737
priority: z.number().optional(),
3838
tpmLimit: z.number().optional(),
3939
tpsGoal: z.number().optional(),
40+
budgetMode: z.enum(["always", "fill"]).optional(),
41+
budgetContribution: z.number().optional(),
4042
weight: z.number().optional(),
4143
disabled: z.boolean().optional(),
4244
storeModel: z.string().optional(),
@@ -54,6 +56,7 @@ export namespace ZenData {
5456
payloadModifier: z.record(z.string(), z.any()).optional(),
5557
payloadMappings: z.record(z.string(), z.string()).optional(),
5658
adjustCacheUsage: z.boolean().optional(),
59+
budget: z.number().optional(),
5760
})
5861

5962
const ModelsSchema = z.object({

0 commit comments

Comments
 (0)