Allow budgets to be set on the resources

This commit is contained in:
Owen
2026-08-14 11:42:33 -04:00
parent 4989d1e31a
commit c8f170d197
4 changed files with 160 additions and 2 deletions
+89
View File
@@ -0,0 +1,89 @@
import { eq } from "drizzle-orm";
import { aiBudgets, Transaction } from "@server/db";
export type BlueprintAiBudgetInput = {
amount: number;
unit: "usd" | "tokens";
period:
| "monthly"
| "yearly"
| "lifetime"
| "daily"
| "hourly"
| "weekly";
enforcement: "hard" | "soft";
enabled: boolean;
};
type SyncAiBudgetsInput = {
orgId: string;
trx: Transaction;
budgets: BlueprintAiBudgetInput[];
} & (
| { scope: "public"; resourceId: number }
| { scope: "site"; siteResourceId: number }
);
/**
* Fully declarative: makes the resource's/site resource's AI budgets match
* exactly what the blueprint declares (omitted unit/period budgets are removed).
*/
export async function syncAiBudgets(input: SyncAiBudgetsInput): Promise<void> {
const { orgId, trx, budgets } = input;
const existing = await trx
.select()
.from(aiBudgets)
.where(
input.scope === "public"
? eq(aiBudgets.resourceId, input.resourceId)
: eq(aiBudgets.siteResourceId, input.siteResourceId)
);
const existingByKey = new Map(
existing.map((b) => [`${b.unit}::${b.period}`, b])
);
const seenKeys = new Set<string>();
const now = Date.now();
for (const budget of budgets) {
const key = `${budget.unit}::${budget.period}`;
seenKeys.add(key);
const existingBudget = existingByKey.get(key);
if (existingBudget) {
await trx
.update(aiBudgets)
.set({
amount: budget.amount,
enforcement: budget.enforcement,
enabled: budget.enabled,
updatedAt: now
})
.where(eq(aiBudgets.budgetId, existingBudget.budgetId));
} else {
await trx.insert(aiBudgets).values({
orgId,
resourceId: input.scope === "public" ? input.resourceId : null,
siteResourceId:
input.scope === "site" ? input.siteResourceId : null,
amount: budget.amount,
unit: budget.unit,
period: budget.period,
enforcement: budget.enforcement,
enabled: budget.enabled,
createdAt: now,
updatedAt: now
});
}
}
for (const [key, existingBudget] of existingByKey) {
if (!seenKeys.has(key)) {
await trx
.delete(aiBudgets)
.where(eq(aiBudgets.budgetId, existingBudget.budgetId));
}
}
}
+17
View File
@@ -30,6 +30,7 @@ import { build } from "@server/build";
import { LimitId } from "../billing"; import { LimitId } from "../billing";
import { usageService } from "../billing/usageService"; import { usageService } from "../billing/usageService";
import { syncInferenceAiConfig } from "./aiProviders"; import { syncInferenceAiConfig } from "./aiProviders";
import { syncAiBudgets } from "./aiBudgets";
async function getDomainForSiteResource( async function getDomainForSiteResource(
siteResourceId: number | undefined, siteResourceId: number | undefined,
@@ -367,6 +368,14 @@ export async function updatePrivateResources(
})) }))
}); });
await syncAiBudgets({
orgId,
trx,
scope: "site",
siteResourceId,
budgets: resourceData["ai-budget"]
});
await trx await trx
.delete(clientSiteResources) .delete(clientSiteResources)
.where(eq(clientSiteResources.siteResourceId, siteResourceId)); .where(eq(clientSiteResources.siteResourceId, siteResourceId));
@@ -668,6 +677,14 @@ export async function updatePrivateResources(
})) }))
}); });
await syncAiBudgets({
orgId,
trx,
scope: "site",
siteResourceId,
budgets: resourceData["ai-budget"]
});
const [adminRole] = await trx const [adminRole] = await trx
.select() .select()
.from(roles) .from(roles)
+17
View File
@@ -57,6 +57,7 @@ import next from "next";
import { LimitId } from "../billing"; import { LimitId } from "../billing";
import { usageService } from "../billing/usageService"; import { usageService } from "../billing/usageService";
import { syncInferenceAiConfig } from "./aiProviders"; import { syncInferenceAiConfig } from "./aiProviders";
import { syncAiBudgets } from "./aiBudgets";
export type PublicResourcesResults = { export type PublicResourcesResults = {
proxyResource: Resource; proxyResource: Resource;
@@ -696,6 +697,14 @@ export async function updatePublicResources(
}) })
) )
}); });
await syncAiBudgets({
orgId,
trx,
scope: "public",
resourceId: existingResource.resourceId,
budgets: resourceData["ai-budget"] || []
});
} }
const existingResourceTargets = await trx const existingResourceTargets = await trx
@@ -1258,6 +1267,14 @@ export async function updatePublicResources(
})) }))
}); });
await syncAiBudgets({
orgId,
trx,
scope: "public",
resourceId: newResource.resourceId,
budgets: resourceData["ai-budget"] || []
});
await trx.insert(roleResources).values({ await trx.insert(roleResources).values({
roleId: adminRole.roleId, roleId: adminRole.roleId,
resourceId: newResource.resourceId resourceId: newResource.resourceId
+37 -2
View File
@@ -5,6 +5,11 @@ import { MaintenanceSchema } from "#dynamic/lib/blueprints/MaintenanceSchema";
import { isValidRegionId } from "@server/db/regions"; import { isValidRegionId } from "@server/db/regions";
import { wildcardSubdomainSchema } from "@server/lib/schemas"; import { wildcardSubdomainSchema } from "@server/lib/schemas";
import config from "@server/lib/config"; import config from "@server/lib/config";
import {
aiBudgetEnforcementSchema,
aiBudgetPeriodSchema,
aiBudgetUnitSchema
} from "@server/routers/aiBudget/validation";
const maxmindDbPath = config.getRawConfig().server.maxmind_db_path; const maxmindDbPath = config.getRawConfig().server.maxmind_db_path;
const maxmindAsnPath = config.getRawConfig().server.maxmind_asn_path; const maxmindAsnPath = config.getRawConfig().server.maxmind_asn_path;
@@ -211,6 +216,33 @@ export const AiProviderAttachmentSchema = z
} }
); );
export const AiBudgetSchema = z.object({
amount: z.number().positive(),
unit: aiBudgetUnitSchema,
period: aiBudgetPeriodSchema.optional().default("monthly"),
enforcement: aiBudgetEnforcementSchema.optional().default("hard"),
enabled: z.boolean().optional().default(true)
});
const aiBudgetArraySchema = z.array(AiBudgetSchema).refine(
(budgets) => {
const keys = budgets.map((b) => `${b.unit}::${b.period}`);
return keys.length === new Set(keys).size;
},
{
message:
"'ai-budget' entries must not overlap: only one budget per unit/period combination is allowed"
}
);
// No default here: an object with only 'targets' set must remain
// recognized as a targets-only resource by isTargetsOnlyResource().
export const AiBudgetListSchema = aiBudgetArraySchema.optional();
export const AiBudgetListSchemaWithDefault = aiBudgetArraySchema
.optional()
.default([]);
export const AuthDaemonSchema = z export const AuthDaemonSchema = z
.object({ .object({
pam: z.enum(["passthrough", "push"]).optional().default("passthrough"), pam: z.enum(["passthrough", "push"]).optional().default("passthrough"),
@@ -257,7 +289,8 @@ export const PublicResourceSchema = z
"proxy-protocol": z.boolean().optional(), "proxy-protocol": z.boolean().optional(),
"proxy-protocol-version": z.int().min(1).optional(), "proxy-protocol-version": z.int().min(1).optional(),
labels: z.array(z.string().min(1)).optional(), labels: z.array(z.string().min(1)).optional(),
"ai-providers": z.array(AiProviderAttachmentSchema).optional() "ai-providers": z.array(AiProviderAttachmentSchema).optional(),
"ai-budget": AiBudgetListSchema
}) })
.refine( .refine(
(resource) => { (resource) => {
@@ -565,7 +598,8 @@ export const PrivateResourceSchema = z
machines: z.array(z.string()).optional().default([]), machines: z.array(z.string()).optional().default([]),
labels: z.array(z.string().min(1)).optional().default([]), labels: z.array(z.string().min(1)).optional().default([]),
"auth-daemon": AuthDaemonSchema.optional(), "auth-daemon": AuthDaemonSchema.optional(),
"ai-providers": z.array(AiProviderAttachmentSchema).optional().default([]) "ai-providers": z.array(AiProviderAttachmentSchema).optional().default([]),
"ai-budget": AiBudgetListSchemaWithDefault
}) })
.refine( .refine(
(data) => { (data) => {
@@ -921,3 +955,4 @@ export type Target = z.infer<typeof TargetSchema>;
export type Resource = z.infer<typeof PublicResourceSchema>; export type Resource = z.infer<typeof PublicResourceSchema>;
export type Config = z.infer<typeof ConfigSchema>; export type Config = z.infer<typeof ConfigSchema>;
export type BlueprintResourcePolicy = z.infer<typeof ResourcePolicySchema>; export type BlueprintResourcePolicy = z.infer<typeof ResourcePolicySchema>;
export type BlueprintAiBudget = z.infer<typeof AiBudgetSchema>;