1
17import { Inject, Injectable } from "@nestjs/common";
18
19import { RedisService } from "../../common/redis.service.js";
20import { logger } from "../../common/logger.js";
21
22
23export const AI_CALLS_PER_USER_LIMIT = 30;
24export const AI_CALLS_RL_WINDOW_MS = 60_000;
25
26export interface AiRateLimitResult {
27 allowed: boolean;
28 remaining: number;
29 retryAfterSeconds: number;
30}
31
32@Injectable()
33export class AiRateLimitService {
34 constructor(@Inject(RedisService) private readonly redis: RedisService) {}
35
36 private lastWarn = 0;
37
38
42 async check(userId: string, limit: number = AI_CALLS_PER_USER_LIMIT): Promise<AiRateLimitResult> {
43 const key = `ai:calls:rl:${userId}`;
44 try {
45 const now = Date.now();
46 const pipe = this.redis.getRedis().pipeline();
47 pipe.zremrangebyscore(key, 0, now - AI_CALLS_RL_WINDOW_MS);
48 pipe.zadd(key, now, `${now}:${Math.random().toString(36).slice(2)}`);
49 pipe.zcard(key);
50 pipe.pexpire(key, AI_CALLS_RL_WINDOW_MS);
51 const results = await pipe.exec();
52 const card = results?.[2]?.[1];
53 const count = typeof card === "number" ? card : 1;
54 const remaining = Math.max(0, limit - count);
55 return {
56 allowed: count <= limit,
57 remaining,
58 retryAfterSeconds: count <= limit ? 0 : Math.ceil(AI_CALLS_RL_WINDOW_MS / 1000),
59 };
60 } catch (err) {
61 if (Date.now() - this.lastWarn > 60_000) {
62 this.lastWarn = Date.now();
63 logger.warn(`ai.calls rate limiter degraded (redis unavailable): ${(err as Error).message}`);
64 }
65 return { allowed: true, remaining: limit, retryAfterSeconds: 0 };
66 }
67 }
68}
69
70
71export function aiCallsRlKey(userId: string): string {
72 return `ai:calls:rl:${userId}`;
73}
74