| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
|
|
| import {
|
| getPool,
|
| getBucket,
|
| incrementBucket,
|
| getPair,
|
| sumPoolDimension,
|
| } from "@/lib/localDb";
|
| import { WINDOW_MS, dimensionKeyToString } from "./dimensions";
|
| import type { DimensionKey } from "./dimensions";
|
| import type { QuotaStore, PoolUsageSnapshot } from "./types";
|
| import { computeBurnRateFromWindow } from "./burnRate";
|
|
|
|
|
|
|
|
|
|
|
| const _mutexes = new Map<string, Promise<void>>();
|
|
|
| function mutexKey(apiKeyId: string, dimKey: string): string {
|
| return `${apiKeyId}|${dimKey}`;
|
| }
|
|
|
| async function withMutex<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
| const current = _mutexes.get(key) ?? Promise.resolve();
|
| let resolve!: () => void;
|
| const next = new Promise<void>((res) => {
|
| resolve = res;
|
| });
|
| _mutexes.set(key, next);
|
|
|
| try {
|
| await current;
|
| return await fn();
|
| } finally {
|
| resolve();
|
|
|
| if (_mutexes.get(key) === next) {
|
| _mutexes.delete(key);
|
| }
|
| }
|
| }
|
|
|
|
|
|
|
|
|
|
|
| function slidingWindowEffective(
|
| curr: number,
|
| prev: number,
|
| nowMs: number,
|
| windowMs: number
|
| ): number {
|
| const currentBucketIndex = Math.floor(nowMs / windowMs);
|
| const currentBucketStartMs = currentBucketIndex * windowMs;
|
| const elapsed = nowMs - currentBucketStartMs;
|
| const weight = 1 - elapsed / windowMs;
|
| return prev * weight + curr;
|
| }
|
|
|
|
|
|
|
|
|
|
|
| export class SqliteQuotaStore implements QuotaStore {
|
| |
| |
| |
|
|
| async consume(apiKeyId: string, dim: DimensionKey, cost: number): Promise<number> {
|
| const nowMs = Date.now();
|
| const dimKey = dimensionKeyToString(dim);
|
| const windowMs = WINDOW_MS[dim.window];
|
| const currentBucket = Math.floor(nowMs / windowMs);
|
|
|
| return withMutex(mutexKey(apiKeyId, dimKey), async () => {
|
|
|
| incrementBucket(apiKeyId, dimKey, currentBucket, cost, nowMs);
|
|
|
|
|
| const { curr, prev } = getPair(apiKeyId, dimKey, currentBucket);
|
| return slidingWindowEffective(curr, prev, nowMs, windowMs);
|
| });
|
| }
|
|
|
| |
| |
|
|
| async peek(apiKeyId: string, dim: DimensionKey): Promise<number> {
|
| const nowMs = Date.now();
|
| const dimKey = dimensionKeyToString(dim);
|
| const windowMs = WINDOW_MS[dim.window];
|
| const currentBucket = Math.floor(nowMs / windowMs);
|
|
|
| const { curr, prev } = getPair(apiKeyId, dimKey, currentBucket);
|
| return slidingWindowEffective(curr, prev, nowMs, windowMs);
|
| }
|
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| async poolConsumedTotal(poolId: string, dim: DimensionKey): Promise<number> {
|
| const nowMs = Date.now();
|
| const dimKey = dimensionKeyToString(dim);
|
| const windowMs = WINDOW_MS[dim.window];
|
| const currentBucket = Math.floor(nowMs / windowMs);
|
|
|
| const { currTotal, prevTotal } = sumPoolDimension(dimKey, currentBucket);
|
| return slidingWindowEffective(currTotal, prevTotal, nowMs, windowMs);
|
| }
|
|
|
| |
| |
| |
| |
|
|
| async poolUsage(poolId: string): Promise<PoolUsageSnapshot> {
|
| const nowMs = Date.now();
|
| const pool = getPool(poolId);
|
|
|
| if (!pool) {
|
| return {
|
| poolId,
|
| generatedAt: new Date(nowMs).toISOString(),
|
| dimensions: [],
|
| };
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
| return {
|
| poolId,
|
| generatedAt: new Date(nowMs).toISOString(),
|
| dimensions: [],
|
| };
|
| }
|
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| async poolUsageWithDimensions(
|
| poolId: string,
|
| planDimensions: Array<{ unit: string; window: string; limit: number }>
|
| ): Promise<PoolUsageSnapshot> {
|
| const nowMs = Date.now();
|
| const pool = getPool(poolId);
|
|
|
| if (!pool) {
|
| return {
|
| poolId,
|
| generatedAt: new Date(nowMs).toISOString(),
|
| dimensions: [],
|
| };
|
| }
|
|
|
| const { allocations } = pool;
|
| const totalWeight = allocations.reduce((sum, a) => sum + a.weight, 0);
|
|
|
| const dimensionSnapshots: PoolUsageSnapshot["dimensions"] = [];
|
|
|
| for (const planDim of planDimensions) {
|
| const windowMs = WINDOW_MS[planDim.window as keyof typeof WINDOW_MS];
|
| if (!windowMs) continue;
|
|
|
| let consumedTotal = 0;
|
| const perKey: PoolUsageSnapshot["dimensions"][number]["perKey"] = [];
|
|
|
| for (const alloc of allocations) {
|
| const dim: DimensionKey = {
|
| poolId,
|
| unit: planDim.unit as DimensionKey["unit"],
|
| window: planDim.window as DimensionKey["window"],
|
| };
|
| const consumed = await this.peek(alloc.apiKeyId, dim);
|
| consumedTotal += consumed;
|
|
|
| const effectiveWeight = totalWeight > 0 ? alloc.weight : 0;
|
| const fairShare = (effectiveWeight / 100) * planDim.limit;
|
| const deficit = consumed - fairShare;
|
| const borrowing = consumed > fairShare;
|
|
|
| perKey.push({
|
| apiKeyId: alloc.apiKeyId,
|
| consumed,
|
| fairShare,
|
| deficit,
|
| borrowing,
|
| });
|
| }
|
|
|
| dimensionSnapshots.push({
|
| unit: planDim.unit as PoolUsageSnapshot["dimensions"][number]["unit"],
|
| window: planDim.window as PoolUsageSnapshot["dimensions"][number]["window"],
|
| limit: planDim.limit,
|
| consumedTotal,
|
| perKey,
|
| });
|
| }
|
|
|
|
|
| const tokenDim = dimensionSnapshots.find((d) => d.unit === "tokens");
|
| let burnRate: PoolUsageSnapshot["burnRate"];
|
| if (tokenDim && tokenDim.consumedTotal > 0) {
|
| const windowMs = WINDOW_MS[tokenDim.window as keyof typeof WINDOW_MS];
|
| const remaining = tokenDim.limit - tokenDim.consumedTotal;
|
| const rateResult = computeBurnRateFromWindow(tokenDim.consumedTotal, windowMs, remaining);
|
| burnRate = {
|
| tokensPerSecond: rateResult.tokensPerSecond,
|
| timeToExhaustionMs: rateResult.timeToExhaustionMs,
|
| };
|
| }
|
|
|
| return {
|
| poolId,
|
| generatedAt: new Date(nowMs).toISOString(),
|
| dimensions: dimensionSnapshots,
|
| burnRate,
|
| };
|
| }
|
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| async clear(apiKeyId: string, dim: DimensionKey): Promise<void> {
|
| const nowMs = Date.now();
|
| const dimKey = dimensionKeyToString(dim);
|
| const windowMs = WINDOW_MS[dim.window];
|
| const currentBucket = Math.floor(nowMs / windowMs);
|
| const prevBucket = currentBucket - 1;
|
|
|
| await withMutex(mutexKey(apiKeyId, dimKey), async () => {
|
|
|
| const currVal = getBucket(apiKeyId, dimKey, currentBucket);
|
| if (currVal !== 0) {
|
| incrementBucket(apiKeyId, dimKey, currentBucket, -currVal, nowMs);
|
| }
|
|
|
| const prevVal = getBucket(apiKeyId, dimKey, prevBucket);
|
| if (prevVal !== 0) {
|
| incrementBucket(apiKeyId, dimKey, prevBucket, -prevVal, nowMs);
|
| }
|
| });
|
| }
|
| }
|
|
|
|
|
| let _instance: SqliteQuotaStore | null = null;
|
|
|
| export function getSqliteQuotaStore(): SqliteQuotaStore {
|
| if (!_instance) {
|
| _instance = new SqliteQuotaStore();
|
| }
|
| return _instance;
|
| }
|
|
|
| export function resetSqliteQuotaStore(): void {
|
| _instance = null;
|
| }
|
|
|