- Add token_usage table with composite unique index for claw_id + date - Add API endpoints: POST /token, GET /token/leaderboard, GET /token/stats - Add TokenLeaderboard component with daily/total period toggle - Add CLI commands: `token` and `stats` for reporting and viewing usage - Add Redis caching for leaderboard with 1-minute TTL - Add shared utilities: authenticateRequest, getTodayDateString - Use UPSERT pattern for atomic token updates - Add i18n translations (en/zh) for new UI elements
114 lines
3.3 KiB
TypeScript
114 lines
3.3 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import { eq, desc, sql } from "drizzle-orm";
|
|
import { db } from "@/lib/db";
|
|
import { claws, tokenUsage } from "@/lib/db/schema";
|
|
import {
|
|
getTokenLeaderboardCache,
|
|
setTokenLeaderboardCache,
|
|
} from "@/lib/redis";
|
|
import { getTodayDateString } from "@/lib/utils";
|
|
|
|
interface LeaderboardEntry {
|
|
clawId: string;
|
|
clawName: string;
|
|
inputTokens: number;
|
|
outputTokens: number;
|
|
totalTokens: number;
|
|
}
|
|
|
|
export async function GET(req: NextRequest) {
|
|
try {
|
|
const { searchParams } = new URL(req.url);
|
|
const period = searchParams.get("period") || "daily";
|
|
const limit = Math.min(parseInt(searchParams.get("limit") || "100"), 100);
|
|
|
|
if (period !== "daily" && period !== "total") {
|
|
return NextResponse.json(
|
|
{ error: "Invalid period. Must be 'daily' or 'total'" },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
const today = getTodayDateString();
|
|
|
|
// Check cache
|
|
const cached = await getTokenLeaderboardCache(
|
|
period as "daily" | "total",
|
|
today
|
|
);
|
|
if (cached) {
|
|
return NextResponse.json(JSON.parse(cached));
|
|
}
|
|
|
|
let leaderboard: LeaderboardEntry[];
|
|
|
|
if (period === "daily") {
|
|
// Daily leaderboard - tokens for today only
|
|
const dailyData = await db
|
|
.select({
|
|
clawId: tokenUsage.clawId,
|
|
clawName: claws.name,
|
|
inputTokens: tokenUsage.inputTokens,
|
|
outputTokens: tokenUsage.outputTokens,
|
|
totalTokens: sql<number>`(${tokenUsage.inputTokens} + ${tokenUsage.outputTokens})`.as("totalTokens"),
|
|
})
|
|
.from(tokenUsage)
|
|
.innerJoin(claws, eq(tokenUsage.clawId, claws.id))
|
|
.where(eq(tokenUsage.date, today))
|
|
.orderBy(desc(sql`(${tokenUsage.inputTokens} + ${tokenUsage.outputTokens})`))
|
|
.limit(limit);
|
|
|
|
leaderboard = dailyData.map((d) => ({
|
|
clawId: d.clawId,
|
|
clawName: d.clawName,
|
|
inputTokens: d.inputTokens,
|
|
outputTokens: d.outputTokens,
|
|
totalTokens: d.inputTokens + d.outputTokens,
|
|
}));
|
|
} else {
|
|
// Total leaderboard - sum of all time
|
|
// Use a raw query for aggregation since Drizzle's groupBy is complex
|
|
const totalData = await db.execute(sql`
|
|
SELECT
|
|
t.claw_id AS clawId,
|
|
c.name AS clawName,
|
|
CAST(SUM(t.input_tokens) AS SIGNED) AS inputTokens,
|
|
CAST(SUM(t.output_tokens) AS SIGNED) AS outputTokens,
|
|
CAST(SUM(t.input_tokens + t.output_tokens) AS SIGNED) AS totalTokens
|
|
FROM token_usage t
|
|
JOIN claws c ON t.claw_id = c.id
|
|
GROUP BY t.claw_id, c.name
|
|
ORDER BY totalTokens DESC
|
|
LIMIT ${limit}
|
|
`);
|
|
|
|
leaderboard = totalData[0] as unknown as LeaderboardEntry[];
|
|
}
|
|
|
|
// Add rank
|
|
const responseData = {
|
|
period,
|
|
date: today,
|
|
leaderboard: leaderboard.map((item, index) => ({
|
|
rank: index + 1,
|
|
...item,
|
|
})),
|
|
};
|
|
|
|
// Cache for 1 minute
|
|
await setTokenLeaderboardCache(
|
|
period as "daily" | "total",
|
|
today,
|
|
JSON.stringify(responseData)
|
|
);
|
|
|
|
return NextResponse.json(responseData);
|
|
} catch (error) {
|
|
console.error("Token leaderboard error:", error);
|
|
return NextResponse.json(
|
|
{ error: "Internal server error" },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|