Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 56 additions & 8 deletions apps/web/src/app/admin/api/free-model-usage/stats/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,32 @@ import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { getUserFromAuth } from '@/lib/user.server';
import { db } from '@/lib/drizzle';
import { free_model_usage } from '@kilocode/db/schema';
import { free_model_usage, kilocode_users } from '@kilocode/db/schema';
import { sql } from 'drizzle-orm';
import {
FREE_MODEL_RATE_LIMIT_WINDOW_HOURS,
FREE_MODEL_MAX_REQUESTS_PER_WINDOW,
ADMIN_RATE_LIMIT_TEST_MODEL,
} from '@/lib/constants';

export type UserAtLimit = {
kiloUserId: string;
requestCount: number;
googleUserName: string | null;
googleUserEmail: string | null;
googleUserImageUrl: string | null;
};

export type FreeModelUsageStatsResponse = {
// Current window stats (last 3 hours)
// Current window stats
windowUniqueIps: number;
windowTotalRequests: number;
windowAvgRequestsPerIp: number;
windowIpsAtRequestLimit: number;
// Anonymous IPs whose anonymous-only request count has reached the limit.
windowAnonymousIpsAtRequestLimit: number;
// Authenticated users whose per-user request count has reached the limit.
windowUsersAtRequestLimit: number;
windowUsersAtLimitList: UserAtLimit[];
windowAnonymousRequests: number;
windowAuthenticatedRequests: number;

Expand Down Expand Up @@ -53,21 +65,50 @@ export async function GET(
sql`${free_model_usage.created_at} >= NOW() - INTERVAL '${sql.raw(String(FREE_MODEL_RATE_LIMIT_WINDOW_HOURS))} hours' AND ${TEST_ROW_FILTER}`
);

// Count IPs at or above the rate limit threshold using a SQL subquery
const ipsAtLimitResult = await db
// Anonymous IPs at the per-IP limit (anonymous-only rows, matching checkFreeModelRateLimit).
const anonymousIpsAtLimitResult = await db
.select({
count: sql<number>`COUNT(*)`,
})
.from(
sql`(
SELECT ${free_model_usage.ip_address}
FROM ${free_model_usage}
WHERE ${free_model_usage.created_at} >= NOW() - INTERVAL '${sql.raw(String(FREE_MODEL_RATE_LIMIT_WINDOW_HOURS))} hours' AND ${TEST_ROW_FILTER}
WHERE ${free_model_usage.created_at} >= NOW() - INTERVAL '${sql.raw(String(FREE_MODEL_RATE_LIMIT_WINDOW_HOURS))} hours'
AND ${TEST_ROW_FILTER}
AND ${free_model_usage.kilo_user_id} IS NULL
GROUP BY ${free_model_usage.ip_address}
HAVING COUNT(*) >= ${FREE_MODEL_MAX_REQUESTS_PER_WINDOW}
) sub`
);

// Authenticated users at the per-user limit (matching checkFreeModelRateLimitByUser).
// Returns the actual user rows (joined with kilocode_users for display) ordered by
// request count desc; the count of all such users is the length of this array.
const usersAtLimitRows = await db
.select({
kiloUserId: free_model_usage.kilo_user_id,
requestCount: sql<number>`COUNT(*)`.as('request_count'),
googleUserName: kilocode_users.google_user_name,
googleUserEmail: kilocode_users.google_user_email,
googleUserImageUrl: kilocode_users.google_user_image_url,
})
.from(free_model_usage)
.leftJoin(kilocode_users, sql`${kilocode_users.id} = ${free_model_usage.kilo_user_id}`)
.where(
sql`${free_model_usage.created_at} >= NOW() - INTERVAL '${sql.raw(String(FREE_MODEL_RATE_LIMIT_WINDOW_HOURS))} hours'
AND ${TEST_ROW_FILTER}
AND ${free_model_usage.kilo_user_id} IS NOT NULL`
)
.groupBy(
free_model_usage.kilo_user_id,
kilocode_users.google_user_name,
kilocode_users.google_user_email,
kilocode_users.google_user_image_url
)
.having(sql`COUNT(*) >= ${FREE_MODEL_MAX_REQUESTS_PER_WINDOW}`)
.orderBy(sql`request_count DESC`);

// Get stats for the last 24 hours
const dailyResult = await db
.select({
Expand All @@ -93,15 +134,22 @@ export async function GET(

const windowUniqueIps = bigIntToNumber(windowStats.unique_ips);
const windowTotalRequests = bigIntToNumber(windowStats.total_requests);
const ipsAtRequestLimit = bigIntToNumber(ipsAtLimitResult[0]?.count ?? 0);

return NextResponse.json({
// Current window stats
windowUniqueIps,
windowTotalRequests,
windowAvgRequestsPerIp:
windowUniqueIps > 0 ? Math.round(windowTotalRequests / windowUniqueIps) : 0,
windowIpsAtRequestLimit: ipsAtRequestLimit,
windowAnonymousIpsAtRequestLimit: bigIntToNumber(anonymousIpsAtLimitResult[0]?.count ?? 0),
windowUsersAtRequestLimit: usersAtLimitRows.length,
windowUsersAtLimitList: usersAtLimitRows.map(row => ({
kiloUserId: row.kiloUserId ?? '',
requestCount: bigIntToNumber(row.requestCount),
googleUserName: row.googleUserName,
googleUserEmail: row.googleUserEmail,
googleUserImageUrl: row.googleUserImageUrl,
})),
windowAnonymousRequests: bigIntToNumber(windowStats.anonymous_requests),
windowAuthenticatedRequests: bigIntToNumber(windowStats.authenticated_requests),

Expand Down
37 changes: 22 additions & 15 deletions apps/web/src/app/admin/components/FreeModelUsageStats.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,10 @@ export function FreeModelUsageStats() {
<Card>
<CardHeader>
<CardTitle>Rate Limit Configuration</CardTitle>
<CardDescription>Current free model rate limit settings (IP-based)</CardDescription>
<CardDescription>
Current free model rate limit settings (per user for authenticated requests, per IP for
anonymous requests)
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 md:grid-cols-2">
Expand Down Expand Up @@ -99,26 +102,30 @@ export function FreeModelUsageStats() {
</CardContent>
</Card>

<Card>
<Card
className={
(data?.windowAnonymousIpsAtRequestLimit ?? 0) > 0
? 'border-destructive bg-destructive/5'
: 'border-primary/40'
}
>
<CardHeader className="pb-2">
<CardTitle className="text-base">IPs at Request Limit</CardTitle>
<CardTitle className="text-base">Anonymous IPs at Limit</CardTitle>
<CardDescription>
IPs that have reached {formatNumber(data?.maxRequestsPerWindow ?? 0)} requests
Anonymous IPs that have reached {formatNumber(data?.maxRequestsPerWindow ?? 0)}{' '}
anonymous requests
</CardDescription>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">
{formatNumber(data?.windowIpsAtRequestLimit ?? 0)}
<div
className={
(data?.windowAnonymousIpsAtRequestLimit ?? 0) > 0
? 'text-destructive text-3xl font-bold'
: 'text-3xl font-bold'
}
>
{formatNumber(data?.windowAnonymousIpsAtRequestLimit ?? 0)}
</div>
{(data?.windowUniqueIps ?? 0) > 0 && (
<div className="text-muted-foreground text-sm">
{(
((data?.windowIpsAtRequestLimit ?? 0) / (data?.windowUniqueIps ?? 1)) *
100
).toFixed(1)}
% of active IPs
</div>
)}
</CardContent>
</Card>
</div>
Expand Down
30 changes: 14 additions & 16 deletions apps/web/src/app/admin/components/RateLimitTesting.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,57 +11,55 @@ export function RateLimitTesting() {
const trpc = useTRPC();
const queryClient = useQueryClient();

const ipUsageQuery = useQuery(trpc.admin.freeModelUsage.getMyIpUsage.queryOptions());
const usageQuery = useQuery(trpc.admin.freeModelUsage.getMyUsage.queryOptions());

const rateLimitMutation = useMutation(
trpc.admin.freeModelUsage.rateLimitMyIp.mutationOptions({
trpc.admin.freeModelUsage.rateLimitMe.mutationOptions({
onSuccess: data => {
if (data.alreadyRateLimited) {
toast.message('Already rate limited', {
description: `IP ${data.ipAddress} already has ${data.newTotal} requests in the current window.`,
description: `User ${data.kiloUserId} already has ${data.newTotal} requests in the current window.`,
});
} else {
toast.success(
`Inserted ${data.rowsInserted} rows for IP ${data.ipAddress}. New total: ${data.newTotal}.`
`Inserted ${data.rowsInserted} rows for user ${data.kiloUserId}. New total: ${data.newTotal}.`
);
}
void queryClient.invalidateQueries({
queryKey: trpc.admin.freeModelUsage.getMyIpUsage.queryKey(),
queryKey: trpc.admin.freeModelUsage.getMyUsage.queryKey(),
});
},
onError: error => {
toast.error(error.message || 'Failed to rate limit IP');
toast.error(error.message || 'Failed to rate limit user');
},
})
);

const data = ipUsageQuery.data;
const data = usageQuery.data;

return (
<Card>
<CardHeader>
<CardTitle>Rate Limit Testing</CardTitle>
<CardDescription>
Insert enough requests to trigger the free model rate limit for your current IP address.
Insert enough requests to trigger the free model rate limit for your own user id.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{ipUsageQuery.isLoading && (
<p className="text-muted-foreground text-sm">Loading IP usage...</p>
)}
{usageQuery.isLoading && <p className="text-muted-foreground text-sm">Loading usage...</p>}

{ipUsageQuery.error && (
{usageQuery.error && (
<p className="text-sm text-red-500">
{ipUsageQuery.error.message || 'Failed to load IP usage'}
{usageQuery.error.message || 'Failed to load usage'}
</p>
)}

{data && (
<>
<div className="grid gap-3 sm:grid-cols-3">
<div>
<p className="text-muted-foreground text-sm">Your IP</p>
<p className="font-mono text-sm font-medium">{data.ipAddress}</p>
<p className="text-muted-foreground text-sm">Your user id</p>
<p className="font-mono text-sm font-medium">{data.kiloUserId}</p>
</div>
<div>
<p className="text-muted-foreground text-sm">Usage ({data.windowHours}h window)</p>
Expand All @@ -88,7 +86,7 @@ export function RateLimitTesting() {
? 'Inserting rows...'
: data.isRateLimited
? 'Already Rate Limited'
: `Rate Limit My IP (insert ${data.limit - data.currentUsage} rows)`}
: `Rate Limit Me (insert ${data.limit - data.currentUsage} rows)`}
</Button>
</>
)}
Expand Down
126 changes: 126 additions & 0 deletions apps/web/src/app/admin/components/UserRateLimitStats.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
'use client';

import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { UserAvatarLink } from './UserAvatarLink';
import type { FreeModelUsageStatsResponse } from '../api/free-model-usage/stats/route';

export function UserRateLimitStats() {
const { data, isLoading, error } = useQuery({
queryKey: ['admin-free-model-usage-stats'],
queryFn: async () => {
const response = await fetch('/admin/api/free-model-usage/stats');
if (!response.ok) {
throw new Error('Failed to fetch free model usage statistics');
}
return (await response.json()) as FreeModelUsageStatsResponse;
},
refetchInterval: 60000,
});

if (error) {
return (
<Card>
<CardHeader>
<CardTitle>Error</CardTitle>
<CardDescription>Failed to load user rate limit statistics</CardDescription>
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-sm">
{error instanceof Error ? error.message : 'An error occurred'}
</p>
</CardContent>
</Card>
);
}

if (isLoading || !data) {
return (
<Card>
<CardHeader>
<CardTitle>Loading...</CardTitle>
<CardDescription>Fetching user rate limit statistics</CardDescription>
</CardHeader>
</Card>
);
}

const usersAtLimit = data.windowUsersAtLimitList;
const isHot = data.windowUsersAtRequestLimit > 0;
const formatNumber = (num: number) => num.toLocaleString();

return (
<div className="space-y-4">
<Card className={isHot ? 'border-destructive bg-destructive/5' : 'border-primary/40'}>
<CardHeader className="pb-2">
<CardTitle className="text-base">Users at Limit</CardTitle>
<CardDescription>
Authenticated users that have reached {formatNumber(data.maxRequestsPerWindow)} requests
in the last {data.rateLimitWindowHours}h
</CardDescription>
</CardHeader>
<CardContent>
<div className={isHot ? 'text-destructive text-3xl font-bold' : 'text-3xl font-bold'}>
{formatNumber(data.windowUsersAtRequestLimit)}
</div>
</CardContent>
</Card>

{usersAtLimit.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base">User IDs at Limit</CardTitle>
<CardDescription>
The authenticated users currently being rate-limited, ordered by request count.
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Kilo user id</TableHead>
<TableHead className="text-right">Requests in window</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{usersAtLimit.map(user => (
<TableRow key={user.kiloUserId}>
<TableCell>
{user.googleUserName ? (
<UserAvatarLink
user={{
id: user.kiloUserId,
google_user_name: user.googleUserName,
google_user_email: user.googleUserEmail ?? '',
google_user_image_url: user.googleUserImageUrl ?? '',
}}
className="flex items-center space-x-3"
displayFormat="email-name"
/>
) : (
<span className="text-muted-foreground text-sm">(unknown user)</span>
)}
</TableCell>
<TableCell className="font-mono text-xs">{user.kiloUserId}</TableCell>
<TableCell className="text-right font-medium">
{formatNumber(user.requestCount)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</div>
);
}
Loading