zen: budget
This commit is contained in:
@@ -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,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
Reference in New Issue
Block a user