zen: budget

This commit is contained in:
Frank
2026-06-29 05:46:56 -04:00
parent beaaa174ea
commit 078385d386
4 changed files with 122 additions and 32 deletions
@@ -137,7 +137,7 @@ export async function handler(
const providerBudgetTracker = createProviderBudgetTracker( const providerBudgetTracker = createProviderBudgetTracker(
modelInfo.providers.map((provider) => ({ ...zenData.providers[provider.id], ...provider })), modelInfo.providers.map((provider) => ({ ...zenData.providers[provider.id], ...provider })),
) )
const providerBudgetUsage = await providerBudgetTracker?.check() const providerBudget = await providerBudgetTracker?.check()
const retriableRequest = async (retry: RetryOptions = { excludeProviders: [], retryCount: 0 }) => { const retriableRequest = async (retry: RetryOptions = { excludeProviders: [], retryCount: 0 }) => {
const providerInfo = selectProvider( const providerInfo = selectProvider(
@@ -151,7 +151,7 @@ export async function handler(
stickyProvider, stickyProvider,
modelTpmLimits, modelTpmLimits,
modelTpsLimits, modelTpsLimits,
providerBudgetUsage, providerBudget,
) )
validateModelSettings(billingSource, authInfo) validateModelSettings(billingSource, authInfo)
updateProviderKey(authInfo, providerInfo) updateProviderKey(authInfo, providerInfo)
@@ -284,7 +284,8 @@ export async function handler(
const costInfo = calculateCost(modelInfo, usageInfo) const costInfo = calculateCost(modelInfo, usageInfo)
await trialLimiter?.track(usageInfo) await trialLimiter?.track(usageInfo)
await modelTpmLimiter?.track(providerInfo.id, providerInfo.model, usageInfo) await modelTpmLimiter?.track(providerInfo.id, providerInfo.model, usageInfo)
await providerBudgetTracker?.track(providerInfo.id, costInfo.totalCostInCent) if (providerInfo.budgetPriority !== undefined)
await providerBudgetTracker?.track(providerInfo.id, providerInfo.budgetPriority, costInfo.totalCostInCent)
await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo) await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo)
await reload(billingSource, authInfo, costInfo) await reload(billingSource, authInfo, costInfo)
json.cost = calculateOccurredCost(billingSource, costInfo) json.cost = calculateOccurredCost(billingSource, costInfo)
@@ -345,7 +346,12 @@ export async function handler(
timestampLastByte, timestampLastByte,
usageInfo, usageInfo,
) )
await providerBudgetTracker?.track(providerInfo.id, costInfo.totalCostInCent) if (providerInfo.budgetPriority !== undefined)
await providerBudgetTracker?.track(
providerInfo.id,
providerInfo.budgetPriority,
costInfo.totalCostInCent,
)
await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo) await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo)
await reload(billingSource, authInfo, costInfo) await reload(billingSource, authInfo, costInfo)
const cost = calculateOccurredCost(billingSource, costInfo) const cost = calculateOccurredCost(billingSource, costInfo)
@@ -512,7 +518,7 @@ export async function handler(
stickyProviderId: string | undefined, stickyProviderId: string | undefined,
modelTpmLimits: Record<string, number> | undefined, modelTpmLimits: Record<string, number> | undefined,
modelTpsLimits: Record<string, { qualify: number; unqualify: number }> | undefined, modelTpsLimits: Record<string, { qualify: number; unqualify: number }> | undefined,
providerBudgetUsage: Record<string, number> | undefined, providerBudget: { qualify: (providerId: string, priority: number) => boolean } | undefined,
) { ) {
const modelProvider = (() => { const modelProvider = (() => {
// Byok is top priority b/c if user set their own API key, we should use it // Byok is top priority b/c if user set their own API key, we should use it
@@ -536,10 +542,9 @@ export async function handler(
.filter((provider) => provider.weight !== 0) .filter((provider) => provider.weight !== 0)
.filter((provider) => !retry.excludeProviders.includes(provider.id)) .filter((provider) => !retry.excludeProviders.includes(provider.id))
.filter((provider) => { .filter((provider) => {
if (provider.budgetMode !== "fill") return true if (provider.budgetPriority === undefined) return true
const budget = zenData.providers[provider.id]?.budget if (!providerBudget) return true
if (budget === undefined) return false return providerBudget.qualify(provider.id, provider.budgetPriority)
return (providerBudgetUsage?.[provider.id] ?? 0) < centsToMicroCents(budget * 100)
}) })
.filter((provider) => { .filter((provider) => {
if (!provider.tpmLimit) return true if (!provider.tpmLimit) return true
@@ -20,7 +20,7 @@ export function createModelTpsLimiter(providers: { id: string; model: string; tp
) )
const now = Date.now() const now = Date.now()
const currInterval = toInterval(new Date(now)) const currInterval = toInterval(new Date(now))
const prevInterval = toInterval(new Date(now - 60 * 1000)) const prevInterval = toInterval(new Date(now - 60_000))
return { return {
check: async () => { check: async () => {
@@ -2,50 +2,135 @@ import { centsToMicroCents } from "@opencode-ai/console-core/util/price.js"
import { buildRateLimitKey, getRedis } from "./redis" import { buildRateLimitKey, getRedis } from "./redis"
import { logger } from "./logger" import { logger } from "./logger"
// Per-provider, per-minute budget with priorities. The budget belongs to a
// provider and is shared across every model that routes to it. Each model's
// provider entry carries a `budgetPriority`: priority 1 ("always") routes
// unconditionally, while higher priorities ("fill") only route while the provider's
// current-minute spend through that priority is still under budget.
//
// Spend is tracked per (provider, priority, minute) so a fill priority can yield its
// leftover headroom to the next priority down. The previous minute is also read so
// higher priorities can reserve the next minute's budget first.
export function createProviderBudgetTracker( export function createProviderBudgetTracker(
providers: { providers: {
id: string id: string
budget?: number budget?: number
budgetContribution?: number budgetContribution?: number
budgetMode?: "always" | "fill" budgetPriority?: number
}[], }[],
) { ) {
const tracked = providers.filter( const tracked = providers.filter(
(provider) => provider.budget !== undefined && provider.budgetContribution !== undefined, (provider) =>
provider.budget !== undefined &&
provider.budgetContribution !== undefined &&
provider.budgetPriority !== undefined,
) )
if (tracked.length === 0) return undefined if (tracked.length === 0) return undefined
const interval = new Date() const intervalAt = (date: Date) =>
date
.toISOString() .toISOString()
.replace(/[^0-9]/g, "") .replace(/[^0-9]/g, "")
.substring(0, 12) .substring(0, 12)
const now = new Date()
const currInterval = intervalAt(now)
const prevInterval = intervalAt(new Date(now.getTime() - 60_000))
const redis = getRedis() const redis = getRedis()
const keys = Object.fromEntries( const key = (providerId: string, priority: number, withInterval: string) =>
tracked.map((provider) => [provider.id, buildRateLimitKey("provider-budget", provider.id, interval)]), buildRateLimitKey("provider-budget", `${providerId}:${priority}`, withInterval)
)
let budgetUsage: Record<string, number> = {} const budgetByProvider = tracked.reduce<Record<string, number>>((acc, provider) => {
acc[provider.id] = provider.budget!
return acc
}, {})
const maxPriorityByProvider = tracked.reduce<Record<string, number>>((acc, provider) => {
acc[provider.id] = Math.max(acc[provider.id] ?? 0, provider.budgetPriority!)
return acc
}, {})
// Effective budget in micro-cents per provider/priority, computed in check()
// from the configured budget minus previous-minute usage from higher priorities.
let effectiveBudget: Record<string, Record<number, number>> = {}
// Cumulative current-minute spend through each priority, per provider.
let spentThroughPriority: Record<string, Record<number, number>> = {}
return { return {
// Returns whether a provider at a given priority still has budget headroom.
// Priority 1 always qualifies; higher priorities qualify only while everything through
// the current priority hasn't already filled the previous-minute adjusted
// budget.
check: async () => { check: async () => {
const ids = tracked.map((provider) => provider.id) const reads = Object.entries(maxPriorityByProvider).flatMap(([providerId, maxPriority]) =>
if (ids.length === 0) return {} Array.from({ length: maxPriority }, (_, index) => index + 1).flatMap((priority) => [
const values = await redis.mget<(string | number | null)[]>(ids.map((id) => keys[id])) { providerId, priority, interval: currInterval, prev: false },
budgetUsage = Object.fromEntries(ids.map((id, index) => [id, Number(values[index] ?? 0)])) { providerId, priority, interval: prevInterval, prev: true },
return budgetUsage ]),
)
const values = await redis.mget<(string | number | null)[]>(
reads.map((r) => key(r.providerId, r.priority, r.interval)),
)
const current: Record<string, Record<number, number>> = {}
const previous: Record<string, Record<number, number>> = {}
reads.forEach((r, index) => {
const amount = Number(values[index] ?? 0)
if (r.prev) {
previous[r.providerId] ??= {}
previous[r.providerId][r.priority] = amount
return
}
current[r.providerId] ??= {}
current[r.providerId][r.priority] = amount
})
effectiveBudget = {}
spentThroughPriority = {}
Object.entries(maxPriorityByProvider).forEach(([providerId, maxPriority]) => {
const providerBudget = budgetByProvider[providerId]
if (providerBudget === undefined) return
const budget = centsToMicroCents(providerBudget * 100)
let currentRunning = 0
let previousRunning = 0
effectiveBudget[providerId] = {}
spentThroughPriority[providerId] = {}
Array.from({ length: maxPriority }, (_, index) => index + 1).forEach((priority) => {
currentRunning += current[providerId]?.[priority] ?? 0
effectiveBudget[providerId][priority] = Math.max(0, budget - previousRunning)
previousRunning += previous[providerId]?.[priority] ?? 0
spentThroughPriority[providerId][priority] = currentRunning
})
})
return {
// Priority 1 is unconditional. Higher priorities gate on the spend through
// the current priority against the effective budget.
qualify: (providerId: string, priority: number) => {
if (priority <= 1) return true
const budget = effectiveBudget[providerId]?.[priority]
if (budget === undefined) return false
const spentThroughCurrentPriority = spentThroughPriority[providerId]?.[priority] ?? 0
return spentThroughCurrentPriority < budget
}, },
track: async (provider: string, costInCent: number) => { }
const config = tracked.find((item) => item.id === provider) },
track: async (provider: string, priority: number, costInCent: number) => {
const config = tracked.find((item) => item.id === provider && item.budgetPriority === priority)
if (!config) return if (!config) return
if (config.budgetContribution === undefined) return if (config.budgetContribution === undefined) return
const cost = centsToMicroCents(costInCent * config.budgetContribution) const cost = centsToMicroCents(costInCent * config.budgetContribution)
if (cost <= 0) return if (cost <= 0) return
const redisKey = key(provider, priority, currInterval)
const pipeline = redis.pipeline() const pipeline = redis.pipeline()
pipeline.incrby(keys[provider], cost) pipeline.incrby(redisKey, cost)
pipeline.expire(keys[provider], 120) // Keep two minutes so the previous interval is readable for budget adjustment.
pipeline.expire(redisKey, 120)
await pipeline.exec() await pipeline.exec()
logger.metric({ logger.metric({
"provider.budget_usage": budgetUsage[provider] + cost, "provider.budget_usage": cost,
"model.budget_usage": cost, "provider.budget_priority": priority,
}) })
}, },
} }
+1 -1
View File
@@ -37,7 +37,7 @@ export namespace ZenData {
priority: z.number().optional(), priority: z.number().optional(),
tpmLimit: z.number().optional(), tpmLimit: z.number().optional(),
tpsGoal: z.number().optional(), tpsGoal: z.number().optional(),
budgetMode: z.enum(["always", "fill"]).optional(), budgetPriority: z.number().optional(),
budgetContribution: z.number().optional(), budgetContribution: z.number().optional(),
weight: z.number().optional(), weight: z.number().optional(),
disabled: z.boolean().optional(), disabled: z.boolean().optional(),