From 70b9cc783143ec379b6f20cd46f1ce043c555534 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 27 May 2026 19:04:55 -0300 Subject: [PATCH] feat(quota): add canonical dimensions, types and Zod schemas --- src/lib/quota/dimensions.ts | 61 +++++++++++++++++++++++++++++++++++++ src/lib/quota/types.ts | 57 ++++++++++++++++++++++++++++++++++ src/shared/schemas/quota.ts | 46 ++++++++++++++++++++++++++++ 3 files changed, 164 insertions(+) create mode 100644 src/lib/quota/dimensions.ts create mode 100644 src/lib/quota/types.ts create mode 100644 src/shared/schemas/quota.ts diff --git a/src/lib/quota/dimensions.ts b/src/lib/quota/dimensions.ts new file mode 100644 index 0000000000..df4c5ab9f9 --- /dev/null +++ b/src/lib/quota/dimensions.ts @@ -0,0 +1,61 @@ +import { z } from "zod"; + +export const QuotaUnitSchema = z.enum(["percent", "requests", "tokens", "usd"]); +export type QuotaUnit = z.infer; + +export const QuotaWindowSchema = z.enum(["5h", "hourly", "daily", "weekly", "monthly"]); +export type QuotaWindow = z.infer; + +export const PolicySchema = z.enum(["hard", "soft", "burst"]); +export type Policy = z.infer; + +export const QuotaDimensionSchema = z.object({ + unit: QuotaUnitSchema, + window: QuotaWindowSchema, + limit: z.number().positive(), +}); +export type QuotaDimension = z.infer; + +export const PoolAllocationSchema = z.object({ + apiKeyId: z.string().min(1), + weight: z.number().min(0).max(100), + capValue: z.number().positive().optional(), + capUnit: QuotaUnitSchema.optional(), + policy: PolicySchema, +}); +export type PoolAllocation = z.infer; + +export const ProviderPlanSchema = z.object({ + connectionId: z.string().nullable(), + provider: z.string().min(1), + dimensions: z.array(QuotaDimensionSchema).min(1), + source: z.enum(["auto", "manual"]), +}); +export type ProviderPlan = z.infer; + +export const QuotaPoolSchema = z.object({ + id: z.string().min(1), + connectionId: z.string().min(1), + name: z.string().min(1), + createdAt: z.string().datetime(), + allocations: z.array(PoolAllocationSchema).default([]), +}); +export type QuotaPool = z.infer; + +export interface DimensionKey { + poolId: string; + unit: QuotaUnit; + window: QuotaWindow; +} + +export const WINDOW_MS: Record = { + hourly: 60 * 60 * 1000, + "5h": 5 * 60 * 60 * 1000, + daily: 24 * 60 * 60 * 1000, + weekly: 7 * 24 * 60 * 60 * 1000, + monthly: 30 * 24 * 60 * 60 * 1000, +}; + +export function dimensionKeyToString(k: DimensionKey): string { + return `${k.poolId}:${k.unit}:${k.window}`; +} diff --git a/src/lib/quota/types.ts b/src/lib/quota/types.ts new file mode 100644 index 0000000000..53bd0daf2a --- /dev/null +++ b/src/lib/quota/types.ts @@ -0,0 +1,57 @@ +import type { DimensionKey, Policy, QuotaDimension } from "./dimensions"; + +export interface PoolUsageSnapshot { + poolId: string; + generatedAt: string; + dimensions: Array<{ + unit: QuotaDimension["unit"]; + window: QuotaDimension["window"]; + limit: number; + consumedTotal: number; + perKey: Array<{ + apiKeyId: string; + consumed: number; + fairShare: number; + deficit: number; + borrowing: boolean; + }>; + }>; + burnRate?: { + tokensPerSecond: number; + timeToExhaustionMs: number | null; + }; +} + +export interface ConsumeResult { + effective: number; + limit: number; + fairShare: number; + allowed: boolean; + policyApplied: Policy; + reason: "ok" | "fair-share" | "cap-absolute" | "global-saturated"; +} + +export interface QuotaStore { + consume(apiKeyId: string, dim: DimensionKey, cost: number): Promise; + peek(apiKeyId: string, dim: DimensionKey): Promise; + poolUsage(poolId: string): Promise; + clear(apiKeyId: string, dim: DimensionKey): Promise; +} + +export interface EnforceInput { + apiKeyId: string; + connectionId: string; + provider: string; + estimatedCost?: { tokens?: number; usd?: number; requests?: number }; +} + +export type EnforceDecision = + | { kind: "allow"; deprioritize?: boolean } + | { kind: "block"; reason: string; httpStatus: 429; retryAfterSeconds?: number }; + +export interface RecordConsumptionInput { + apiKeyId: string; + connectionId: string; + provider: string; + cost: { tokens?: number; usd?: number; requests?: number }; +} diff --git a/src/shared/schemas/quota.ts b/src/shared/schemas/quota.ts new file mode 100644 index 0000000000..8631cac623 --- /dev/null +++ b/src/shared/schemas/quota.ts @@ -0,0 +1,46 @@ +import { z } from "zod"; +import { PoolAllocationSchema, QuotaDimensionSchema } from "@/lib/quota/dimensions"; + +export const PoolCreateSchema = z.object({ + connectionId: z.string().min(1), + name: z.string().min(1).max(120), + allocations: z.array(PoolAllocationSchema).default([]), +}); +export type PoolCreate = z.infer; + +export const PoolUpdateSchema = z.object({ + name: z.string().min(1).max(120).optional(), + allocations: z.array(PoolAllocationSchema).optional(), +}); +export type PoolUpdate = z.infer; + +export const PlanUpsertSchema = z.object({ + dimensions: z.array(QuotaDimensionSchema).min(1), +}); +export type PlanUpsert = z.infer; + +export const QuotaStoreSettingsSchema = z.object({ + driver: z.enum(["sqlite", "redis"]), + redisUrl: z.string().url().nullable().optional(), +}); +export type QuotaStoreSettings = z.infer; + +export const QuotaPreviewQuerySchema = z.object({ + apiKeyId: z.string().min(1), + poolId: z.string().min(1), + estimatedTokens: z.coerce.number().nonnegative().optional(), + estimatedUsd: z.coerce.number().nonnegative().optional(), + estimatedRequests: z.coerce.number().int().nonnegative().optional(), +}); +export type QuotaPreviewQuery = z.infer; + +export const AuditLogQuerySchema = z.object({ + action: z.string().optional(), + actor: z.string().optional(), + level: z.enum(["high", "all"]).default("all"), + from: z.string().datetime().optional(), + to: z.string().datetime().optional(), + limit: z.coerce.number().int().min(1).max(500).default(50), + offset: z.coerce.number().int().min(0).max(10_000).default(0), +}); +export type AuditLogQuery = z.infer;