mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-15 08:49:59 +02:00
Allow budgets to be set on the resources
This commit is contained in:
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
Reference in New Issue
Block a user