Files
wehub-resource-sync e30e75b5d4
Changesets / Create Version PR (push) Has been cancelled
Deploy Shadcn Registry / Deploy Production (push) Has been cancelled
Template Metrics / LOC + Bundle Size (push) Has been cancelled
Code Quality / Oxlint + Oxfmt (push) Has been cancelled
Code Quality / Template Sync (push) Has been cancelled
Code Quality / Build Changed Packages (push) Has been cancelled
Code Quality / Test Changed Packages (push) Has been cancelled
Deploy Expo Example / Deploy Production (push) Has been cancelled
Deploy Ink Example / Deploy Production (push) Has been cancelled
Python Tests / pytest (assistant-stream, 3.10) (push) Has been cancelled
Python Tests / pytest (assistant-stream, 3.12) (push) Has been cancelled
Python Tests / pytest (assistant-ui-sync-server-api, 3.10) (push) Has been cancelled
Python Tests / pytest (assistant-ui-sync-server-api, 3.12) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:40:13 +08:00

316 lines
9.0 KiB
TypeScript

import type { Redis } from "@upstash/redis";
import {
budgetDenyMessage,
evaluateBudget,
estimateMicroUsd,
getBudgetLimits,
utcDateKey,
type BudgetDenyCode,
type TokenUsage,
} from "./usage-budget-codes";
export type {
BudgetDenyCode,
BudgetLimits,
BudgetSnapshot,
} from "./usage-budget-codes";
export {
evaluateBudget,
estimateMicroUsd,
getBudgetLimits,
budgetDenyMessage,
isDailyLimitCode,
} from "./usage-budget-codes";
const SESSION_TTL_SEC = 7 * 24 * 60 * 60;
const DAY_TTL_SEC = 48 * 60 * 60;
function sessionTurnsKey(sessionId: string): string {
return `xulux:sess:${sessionId}:turns`;
}
function sessionSpentKey(sessionId: string): string {
return `xulux:sess:${sessionId}:spent`;
}
function dailySpentKey(distinctId: string, date: string): string {
return `xulux:day:${distinctId}:${date}:spent`;
}
function dailySessionsKey(distinctId: string, date: string): string {
return `xulux:day:${distinctId}:${date}:sessions`;
}
function sessionRegisteredKey(sessionId: string, date: string): string {
return `xulux:sess:${sessionId}:reg:${date}`;
}
export function isUsageBudgetEnabled(): boolean {
if (process.env.XULUX_USAGE_BUDGET_ENABLED === "0") return false;
return true;
}
type UsageBudgetStore = {
loadSnapshot: (
sessionId: string,
distinctId: string,
date: string,
) => Promise<import("./usage-budget-codes").BudgetSnapshot>;
registerChat: (
sessionId: string,
distinctId: string,
date: string,
) => Promise<number>;
incrementTurn: (sessionId: string) => Promise<number>;
addSpend: (
sessionId: string,
distinctId: string,
date: string,
microUsd: number,
) => Promise<void>;
};
function pruneStaleMemoryDailyKeys(
dailySpent: Map<string, number>,
dailySessions: Map<string, Set<string>>,
currentDate: string,
) {
for (const key of dailySpent.keys()) {
if (!key.endsWith(`:${currentDate}`)) dailySpent.delete(key);
}
for (const key of dailySessions.keys()) {
if (!key.endsWith(`:${currentDate}`)) dailySessions.delete(key);
}
}
function createMemoryStore(): UsageBudgetStore {
const turns = new Map<string, number>();
const sessionSpent = new Map<string, number>();
const dailySpent = new Map<string, number>();
const dailySessions = new Map<string, Set<string>>();
const registered = new Set<string>();
return {
async loadSnapshot(sessionId, distinctId, date) {
pruneStaleMemoryDailyKeys(dailySpent, dailySessions, date);
const sessionTurns = turns.get(sessionId) ?? 0;
return {
sessionTurns,
sessionSpentMicroUsd: sessionSpent.get(sessionId) ?? 0,
dailySpentMicroUsd: dailySpent.get(`${distinctId}:${date}`) ?? 0,
dailyChatCount: dailySessions.get(`${distinctId}:${date}`)?.size ?? 0,
isNewSession:
sessionTurns === 0 && !registered.has(`${sessionId}:${date}`),
};
},
async registerChat(sessionId, distinctId, date) {
const regKey = `${sessionId}:${date}`;
if (registered.has(regKey)) {
return dailySessions.get(`${distinctId}:${date}`)?.size ?? 0;
}
registered.add(regKey);
const dayKey = `${distinctId}:${date}`;
const set = dailySessions.get(dayKey) ?? new Set<string>();
set.add(sessionId);
dailySessions.set(dayKey, set);
return set.size;
},
async incrementTurn(sessionId) {
const next = (turns.get(sessionId) ?? 0) + 1;
turns.set(sessionId, next);
return next;
},
async addSpend(sessionId, distinctId, date, microUsd) {
sessionSpent.set(
sessionId,
(sessionSpent.get(sessionId) ?? 0) + microUsd,
);
const dayKey = `${distinctId}:${date}`;
dailySpent.set(dayKey, (dailySpent.get(dayKey) ?? 0) + microUsd);
},
};
}
function createRedisStore(redis: Redis): UsageBudgetStore {
return {
async loadSnapshot(sessionId, distinctId, date) {
const [
sessionTurnsRaw,
sessionSpentRaw,
dailySpentRaw,
chatCount,
registered,
] = await Promise.all([
redis.get<number>(sessionTurnsKey(sessionId)),
redis.get<number>(sessionSpentKey(sessionId)),
redis.get<number>(dailySpentKey(distinctId, date)),
redis.scard(dailySessionsKey(distinctId, date)),
redis.get(sessionRegisteredKey(sessionId, date)),
]);
const sessionTurns = sessionTurnsRaw ?? 0;
return {
sessionTurns,
sessionSpentMicroUsd: sessionSpentRaw ?? 0,
dailySpentMicroUsd: dailySpentRaw ?? 0,
dailyChatCount: chatCount ?? 0,
isNewSession: sessionTurns === 0 && !registered,
};
},
async registerChat(sessionId, distinctId, date) {
const regKey = sessionRegisteredKey(sessionId, date);
const wasSet = await redis.set(regKey, "1", {
nx: true,
ex: DAY_TTL_SEC,
});
if (!wasSet) {
return redis.scard(dailySessionsKey(distinctId, date));
}
await redis.sadd(dailySessionsKey(distinctId, date), sessionId);
await redis.expire(dailySessionsKey(distinctId, date), DAY_TTL_SEC);
return redis.scard(dailySessionsKey(distinctId, date));
},
async incrementTurn(sessionId) {
const turns = await redis.incr(sessionTurnsKey(sessionId));
await redis.expire(sessionTurnsKey(sessionId), SESSION_TTL_SEC);
return turns;
},
async addSpend(sessionId, distinctId, date, microUsd) {
await Promise.all([
redis.incrby(sessionSpentKey(sessionId), microUsd),
redis.incrby(dailySpentKey(distinctId, date), microUsd),
]);
await Promise.all([
redis.expire(sessionSpentKey(sessionId), SESSION_TTL_SEC),
redis.expire(dailySpentKey(distinctId, date), DAY_TTL_SEC),
]);
},
};
}
let storePromise: Promise<UsageBudgetStore | null> | null = null;
let memoryStore: UsageBudgetStore | null = null;
async function getStore(): Promise<UsageBudgetStore | null> {
if (!isUsageBudgetEnabled()) return null;
const forceMemory =
process.env.XULUX_USAGE_BUDGET_STORE === "memory" ||
!process.env.UPSTASH_REDIS_REST_URL;
if (forceMemory) {
memoryStore ??= createMemoryStore();
return memoryStore;
}
storePromise ??= (async () => {
try {
const { Redis } = await import("@upstash/redis");
return createRedisStore(Redis.fromEnv());
} catch (error) {
console.error(
"[usage-budget] Redis init failed; using memory store",
error,
);
memoryStore ??= createMemoryStore();
return memoryStore;
}
})();
return storePromise;
}
export type BeginTurnResult = {
denied: Response | null;
budgetDate: string;
};
function denyResponse(code: BudgetDenyCode): Response {
const limits = getBudgetLimits();
return Response.json(
{ error: budgetDenyMessage(code, limits), code },
{ status: 429 },
);
}
export async function beginTurn(
sessionId: string,
distinctId: string,
): Promise<BeginTurnResult> {
const budgetDate = utcDateKey();
const store = await getStore();
if (!store) return { denied: null, budgetDate };
const limits = getBudgetLimits();
const snap = await store.loadSnapshot(sessionId, distinctId, budgetDate);
const deny = evaluateBudget(snap, limits);
if (deny) return { denied: denyResponse(deny), budgetDate };
if (snap.isNewSession) {
const chatCount = await store.registerChat(
sessionId,
distinctId,
budgetDate,
);
if (chatCount > limits.maxChatsPerDay) {
return { denied: denyResponse("DAILY_CHAT_LIMIT"), budgetDate };
}
}
await store.incrementTurn(sessionId);
return { denied: null, budgetDate };
}
export async function finishTurn(
sessionId: string,
distinctId: string,
usage: TokenUsage,
modelId?: string,
budgetDate = utcDateKey(),
): Promise<void> {
const store = await getStore();
if (!store) return;
const microUsd = estimateMicroUsd(usage, modelId);
await store.addSpend(sessionId, distinctId, budgetDate, microUsd);
}
/** @internal Test-only memory store for integration checks. */
export function createTestUsageBudgetStore(): UsageBudgetStore {
return createMemoryStore();
}
/** @internal Runs beginTurn/finishTurn against an injected store (tests). */
export async function beginTurnWithStore(
store: UsageBudgetStore,
sessionId: string,
distinctId: string,
date = utcDateKey(),
): Promise<Response | null> {
const limits = getBudgetLimits();
const snap = await store.loadSnapshot(sessionId, distinctId, date);
const deny = evaluateBudget(snap, limits);
if (deny) return denyResponse(deny);
if (snap.isNewSession) {
const chatCount = await store.registerChat(sessionId, distinctId, date);
if (chatCount > limits.maxChatsPerDay) {
return denyResponse("DAILY_CHAT_LIMIT");
}
}
await store.incrementTurn(sessionId);
return null;
}
export async function finishTurnWithStore(
store: UsageBudgetStore,
sessionId: string,
distinctId: string,
usage: TokenUsage,
modelId?: string,
date = utcDateKey(),
): Promise<void> {
const microUsd = estimateMicroUsd(usage, modelId);
await store.addSpend(sessionId, distinctId, date, microUsd);
}