feat: add token usage tracking and leaderboard
- 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
This commit is contained in:
@@ -12,6 +12,7 @@ import { StatsPanel } from "@/components/dashboard/stats-panel";
|
||||
import { ActivityTimeline } from "@/components/dashboard/activity-timeline";
|
||||
import { ClawFeed } from "@/components/dashboard/claw-feed";
|
||||
import { RegionRanking } from "@/components/dashboard/region-ranking";
|
||||
import { TokenLeaderboard } from "@/components/dashboard/token-leaderboard";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const WorldMap = dynamic(
|
||||
@@ -55,6 +56,7 @@ export default function HomePage() {
|
||||
<div className="flex flex-col gap-4">
|
||||
<StatsPanel />
|
||||
<RegionRanking />
|
||||
<TokenLeaderboard />
|
||||
</div>
|
||||
|
||||
{/* Center - Map/Globe + Timeline */}
|
||||
|
||||
113
app/api/v1/token/leaderboard/route.ts
Normal file
113
app/api/v1/token/leaderboard/route.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
55
app/api/v1/token/route.ts
Normal file
55
app/api/v1/token/route.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
import { invalidateTokenLeaderboardCache } from "@/lib/redis";
|
||||
import { authenticateRequest, getTodayDateString } from "@/lib/utils";
|
||||
import { tokenSchema } from "@/lib/validators/schemas";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const auth = await authenticateRequest(req);
|
||||
if (auth instanceof NextResponse) {
|
||||
return auth;
|
||||
}
|
||||
const { claw } = auth;
|
||||
|
||||
const body = await req.json();
|
||||
const parsed = tokenSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Validation failed", details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { inputTokens, outputTokens, date } = parsed.data;
|
||||
const targetDate = date || getTodayDateString();
|
||||
|
||||
// Use UPSERT (INSERT ... ON DUPLICATE KEY UPDATE) for atomic operation
|
||||
await db.execute(sql`
|
||||
INSERT INTO token_usage (claw_id, date, input_tokens, output_tokens)
|
||||
VALUES (${claw.id}, ${targetDate}, ${inputTokens}, ${outputTokens})
|
||||
ON DUPLICATE KEY UPDATE
|
||||
input_tokens = ${inputTokens},
|
||||
output_tokens = ${outputTokens},
|
||||
updated_at = NOW()
|
||||
`);
|
||||
|
||||
// Invalidate leaderboard cache
|
||||
await invalidateTokenLeaderboardCache();
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
date: targetDate,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
totalTokens: inputTokens + outputTokens,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Token report error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
91
app/api/v1/token/stats/route.ts
Normal file
91
app/api/v1/token/stats/route.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { eq, and, gte, sql } from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
import { tokenUsage } from "@/lib/db/schema";
|
||||
import { authenticateRequest, getTodayDateString } from "@/lib/utils";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const auth = await authenticateRequest(req);
|
||||
if (auth instanceof NextResponse) {
|
||||
return auth;
|
||||
}
|
||||
const { claw } = auth;
|
||||
|
||||
const today = getTodayDateString();
|
||||
|
||||
// Get total usage using raw query for aggregation
|
||||
const totalResult = await db.execute(sql`
|
||||
SELECT
|
||||
COALESCE(SUM(input_tokens), 0) AS inputTokens,
|
||||
COALESCE(SUM(output_tokens), 0) AS outputTokens
|
||||
FROM token_usage
|
||||
WHERE claw_id = ${claw.id}
|
||||
`);
|
||||
|
||||
const totalRow = totalResult[0] as unknown as { inputTokens: number; outputTokens: number } | undefined;
|
||||
const totalStats = totalRow
|
||||
? {
|
||||
inputTokens: Number(totalRow.inputTokens || 0),
|
||||
outputTokens: Number(totalRow.outputTokens || 0),
|
||||
totalTokens: Number(totalRow.inputTokens || 0) + Number(totalRow.outputTokens || 0),
|
||||
}
|
||||
: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalTokens: 0,
|
||||
};
|
||||
|
||||
// Get last 30 days history
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
const thirtyDaysAgoStr = thirtyDaysAgo.toISOString().split("T")[0];
|
||||
|
||||
const history = await db
|
||||
.select({
|
||||
date: tokenUsage.date,
|
||||
inputTokens: tokenUsage.inputTokens,
|
||||
outputTokens: tokenUsage.outputTokens,
|
||||
})
|
||||
.from(tokenUsage)
|
||||
.where(
|
||||
and(
|
||||
eq(tokenUsage.clawId, claw.id),
|
||||
gte(tokenUsage.date, thirtyDaysAgoStr)
|
||||
)
|
||||
);
|
||||
|
||||
// Derive today's stats from history (avoids redundant query)
|
||||
const todayRecord = history.find((h) => h.date === today);
|
||||
const todayStats = todayRecord
|
||||
? {
|
||||
inputTokens: todayRecord.inputTokens,
|
||||
outputTokens: todayRecord.outputTokens,
|
||||
totalTokens: todayRecord.inputTokens + todayRecord.outputTokens,
|
||||
}
|
||||
: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalTokens: 0,
|
||||
};
|
||||
|
||||
return NextResponse.json({
|
||||
clawId: claw.id,
|
||||
clawName: claw.name,
|
||||
today: todayStats,
|
||||
total: totalStats,
|
||||
history: history.map((h) => ({
|
||||
date: h.date,
|
||||
inputTokens: h.inputTokens,
|
||||
outputTokens: h.outputTokens,
|
||||
totalTokens: h.inputTokens + h.outputTokens,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Token stats error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
155
components/dashboard/token-leaderboard.tsx
Normal file
155
components/dashboard/token-leaderboard.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
import { motion } from "framer-motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface LeaderboardEntry {
|
||||
rank: number;
|
||||
clawId: string;
|
||||
clawName: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
}
|
||||
|
||||
interface LeaderboardData {
|
||||
period: string;
|
||||
date: string;
|
||||
leaderboard: LeaderboardEntry[];
|
||||
}
|
||||
|
||||
type Period = "daily" | "total";
|
||||
|
||||
function formatTokens(num: number): string {
|
||||
if (num >= 1000000) {
|
||||
return `${(num / 1000000).toFixed(1)}M`;
|
||||
}
|
||||
if (num >= 1000) {
|
||||
return `${(num / 1000).toFixed(1)}K`;
|
||||
}
|
||||
return num.toString();
|
||||
}
|
||||
|
||||
export function TokenLeaderboard() {
|
||||
const t = useTranslations("tokenLeaderboard");
|
||||
const [period, setPeriod] = useState<Period>("daily");
|
||||
const [data, setData] = useState<LeaderboardEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/token/leaderboard?period=${period}`);
|
||||
if (res.ok) {
|
||||
const json: LeaderboardData = await res.json();
|
||||
setData(json.leaderboard);
|
||||
}
|
||||
} catch {
|
||||
// retry on interval
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
setLoading(true);
|
||||
fetchData();
|
||||
const interval = setInterval(fetchData, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [period]);
|
||||
|
||||
const maxTokens = useMemo(
|
||||
() => Math.max(...data.map((d) => d.totalTokens), 1),
|
||||
[data]
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className="border-white/5">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>{t("title")}</CardTitle>
|
||||
<div className="flex rounded-md border border-white/10 bg-white/5 p-0.5">
|
||||
<button
|
||||
onClick={() => setPeriod("daily")}
|
||||
className={cn(
|
||||
"rounded px-2 py-0.5 text-xs font-medium transition-all",
|
||||
period === "daily"
|
||||
? "bg-[var(--accent-cyan)]/20 text-[var(--accent-cyan)]"
|
||||
: "text-[var(--text-muted)] hover:text-[var(--text-secondary)]"
|
||||
)}
|
||||
>
|
||||
{t("daily")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPeriod("total")}
|
||||
className={cn(
|
||||
"rounded px-2 py-0.5 text-xs font-medium transition-all",
|
||||
period === "total"
|
||||
? "bg-[var(--accent-cyan)]/20 text-[var(--accent-cyan)]"
|
||||
: "text-[var(--text-muted)] hover:text-[var(--text-secondary)]"
|
||||
)}
|
||||
>
|
||||
{t("total")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 p-4 pt-0">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-[var(--accent-cyan)] border-t-transparent" />
|
||||
</div>
|
||||
) : data.length === 0 ? (
|
||||
<p className="py-4 text-center text-xs text-[var(--text-muted)]">{t("noData")}</p>
|
||||
) : (
|
||||
data.slice(0, 10).map((entry) => (
|
||||
<div key={entry.clawId} className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-5 w-5 items-center justify-center rounded text-xs font-bold",
|
||||
entry.rank === 1
|
||||
? "bg-yellow-500/20 text-yellow-400"
|
||||
: entry.rank === 2
|
||||
? "bg-gray-400/20 text-gray-300"
|
||||
: entry.rank === 3
|
||||
? "bg-orange-500/20 text-orange-400"
|
||||
: "bg-white/5 text-[var(--text-muted)]"
|
||||
)}
|
||||
>
|
||||
{entry.rank}
|
||||
</span>
|
||||
<span className="max-w-[100px] truncate text-sm font-medium">
|
||||
{entry.clawName}
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-mono text-xs text-[var(--accent-cyan)]">
|
||||
{formatTokens(entry.totalTokens)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1 overflow-hidden rounded-full bg-white/5">
|
||||
<motion.div
|
||||
className="h-full rounded-full bg-gradient-to-r from-[var(--accent-cyan)] to-[var(--accent-purple)]"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${(entry.totalTokens / maxTokens) * 100}%` }}
|
||||
transition={{ duration: 0.5, ease: "easeOut" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<span className="font-mono text-[10px] text-[var(--text-muted)]">
|
||||
{t("inputOutput", {
|
||||
input: formatTokens(entry.inputTokens),
|
||||
output: formatTokens(entry.outputTokens),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
69
drizzle/0000_public_leo.sql
Normal file
69
drizzle/0000_public_leo.sql
Normal file
@@ -0,0 +1,69 @@
|
||||
CREATE TABLE `claws` (
|
||||
`id` varchar(21) NOT NULL,
|
||||
`api_key` varchar(64) NOT NULL,
|
||||
`name` varchar(100) NOT NULL,
|
||||
`platform` varchar(20),
|
||||
`model` varchar(50),
|
||||
`ip` varchar(45),
|
||||
`latitude` decimal(10,7),
|
||||
`longitude` decimal(10,7),
|
||||
`city` varchar(100),
|
||||
`country` varchar(100),
|
||||
`country_code` varchar(5),
|
||||
`region` varchar(50),
|
||||
`last_heartbeat` datetime,
|
||||
`total_tasks` int DEFAULT 0,
|
||||
`created_at` datetime DEFAULT NOW(),
|
||||
`updated_at` datetime DEFAULT NOW(),
|
||||
CONSTRAINT `claws_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `claws_api_key_unique` UNIQUE(`api_key`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `geo_cache` (
|
||||
`ip` varchar(45) NOT NULL,
|
||||
`latitude` decimal(10,7),
|
||||
`longitude` decimal(10,7),
|
||||
`city` varchar(100),
|
||||
`country` varchar(100),
|
||||
`country_code` varchar(5),
|
||||
`region` varchar(50),
|
||||
`updated_at` datetime DEFAULT NOW(),
|
||||
CONSTRAINT `geo_cache_ip` PRIMARY KEY(`ip`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `heartbeats` (
|
||||
`id` bigint AUTO_INCREMENT NOT NULL,
|
||||
`claw_id` varchar(21) NOT NULL,
|
||||
`ip` varchar(45),
|
||||
`timestamp` datetime DEFAULT NOW(),
|
||||
CONSTRAINT `heartbeats_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `tasks` (
|
||||
`id` bigint AUTO_INCREMENT NOT NULL,
|
||||
`claw_id` varchar(21) NOT NULL,
|
||||
`summary` varchar(500),
|
||||
`duration_ms` int,
|
||||
`model` varchar(50),
|
||||
`tools_used` json,
|
||||
`timestamp` datetime DEFAULT NOW(),
|
||||
CONSTRAINT `tasks_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `token_usage` (
|
||||
`id` bigint AUTO_INCREMENT NOT NULL,
|
||||
`claw_id` varchar(21) NOT NULL,
|
||||
`date` varchar(10) NOT NULL,
|
||||
`input_tokens` bigint NOT NULL DEFAULT 0,
|
||||
`output_tokens` bigint NOT NULL DEFAULT 0,
|
||||
`created_at` datetime DEFAULT NOW(),
|
||||
`updated_at` datetime DEFAULT NOW(),
|
||||
CONSTRAINT `token_usage_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `heartbeats_claw_id_idx` ON `heartbeats` (`claw_id`);--> statement-breakpoint
|
||||
CREATE INDEX `heartbeats_timestamp_idx` ON `heartbeats` (`timestamp`);--> statement-breakpoint
|
||||
CREATE INDEX `tasks_claw_id_idx` ON `tasks` (`claw_id`);--> statement-breakpoint
|
||||
CREATE INDEX `tasks_timestamp_idx` ON `tasks` (`timestamp`);--> statement-breakpoint
|
||||
CREATE INDEX `token_usage_claw_id_idx` ON `token_usage` (`claw_id`);--> statement-breakpoint
|
||||
CREATE INDEX `token_usage_date_idx` ON `token_usage` (`date`);
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
datetime,
|
||||
json,
|
||||
index,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/mysql-core";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
@@ -70,3 +71,21 @@ export const geoCache = mysqlTable("geo_cache", {
|
||||
region: varchar("region", { length: 50 }),
|
||||
updatedAt: datetime("updated_at").default(sql`NOW()`),
|
||||
});
|
||||
|
||||
export const tokenUsage = mysqlTable(
|
||||
"token_usage",
|
||||
{
|
||||
id: bigint("id", { mode: "number" }).primaryKey().autoincrement(),
|
||||
clawId: varchar("claw_id", { length: 21 }).notNull(),
|
||||
date: varchar("date", { length: 10 }).notNull(), // YYYY-MM-DD
|
||||
inputTokens: bigint("input_tokens", { mode: "number" }).notNull().default(0),
|
||||
outputTokens: bigint("output_tokens", { mode: "number" }).notNull().default(0),
|
||||
createdAt: datetime("created_at").default(sql`NOW()`),
|
||||
updatedAt: datetime("updated_at").default(sql`NOW()`),
|
||||
},
|
||||
(table) => [
|
||||
index("token_usage_claw_id_idx").on(table.clawId),
|
||||
index("token_usage_date_idx").on(table.date),
|
||||
uniqueIndex("token_usage_claw_date_unq").on(table.clawId, table.date),
|
||||
]
|
||||
);
|
||||
|
||||
@@ -105,3 +105,30 @@ export async function getCacheHeatmap(): Promise<string | null> {
|
||||
export async function publishEvent(event: object): Promise<void> {
|
||||
await redis.publish(CHANNEL_REALTIME, JSON.stringify(event));
|
||||
}
|
||||
|
||||
// Token Leaderboard Cache
|
||||
const TOKEN_LEADERBOARD_DAILY_KEY = "cache:token_leaderboard:daily";
|
||||
const TOKEN_LEADERBOARD_TOTAL_KEY = "cache:token_leaderboard:total";
|
||||
|
||||
export async function setTokenLeaderboardCache(
|
||||
period: "daily" | "total",
|
||||
date: string,
|
||||
data: string
|
||||
): Promise<void> {
|
||||
const key = period === "daily" ? TOKEN_LEADERBOARD_DAILY_KEY : TOKEN_LEADERBOARD_TOTAL_KEY;
|
||||
await redis.set(`${key}:${date}`, data, "EX", 60); // 1 minute cache
|
||||
}
|
||||
|
||||
export async function getTokenLeaderboardCache(
|
||||
period: "daily" | "total",
|
||||
date: string
|
||||
): Promise<string | null> {
|
||||
const key = period === "daily" ? TOKEN_LEADERBOARD_DAILY_KEY : TOKEN_LEADERBOARD_TOTAL_KEY;
|
||||
return redis.get(`${key}:${date}`);
|
||||
}
|
||||
|
||||
export async function invalidateTokenLeaderboardCache(): Promise<void> {
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
await redis.del(`${TOKEN_LEADERBOARD_DAILY_KEY}:${today}`);
|
||||
await redis.del(`${TOKEN_LEADERBOARD_TOTAL_KEY}:${today}`);
|
||||
}
|
||||
|
||||
31
lib/utils.ts
31
lib/utils.ts
@@ -1,6 +1,37 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { validateApiKey } from "@/lib/auth/api-key";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get today's date as YYYY-MM-DD string
|
||||
*/
|
||||
export function getTodayDateString(): string {
|
||||
return new Date().toISOString().split("T")[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate an API request with Bearer token
|
||||
* Returns the claw object if authenticated, or a 401 NextResponse if not
|
||||
*/
|
||||
export async function authenticateRequest(req: NextRequest) {
|
||||
const authHeader = req.headers.get("authorization");
|
||||
if (!authHeader?.startsWith("Bearer ")) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing or invalid authorization header" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const apiKey = authHeader.slice(7);
|
||||
const claw = await validateApiKey(apiKey);
|
||||
if (!claw) {
|
||||
return NextResponse.json({ error: "Invalid API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
return { claw };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const MAX_TOKENS = 1_000_000_000_000; // 1 trillion
|
||||
|
||||
export const registerSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
platform: z.string().optional(),
|
||||
@@ -19,6 +21,13 @@ export const taskSchema = z.object({
|
||||
toolsUsed: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export const tokenSchema = z.object({
|
||||
inputTokens: z.number().int().nonnegative().max(MAX_TOKENS),
|
||||
outputTokens: z.number().int().nonnegative().max(MAX_TOKENS),
|
||||
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||
});
|
||||
|
||||
export type RegisterInput = z.infer<typeof registerSchema>;
|
||||
export type HeartbeatInput = z.infer<typeof heartbeatSchema>;
|
||||
export type TaskInput = z.infer<typeof taskSchema>;
|
||||
export type TokenInput = z.infer<typeof tokenSchema>;
|
||||
|
||||
@@ -27,6 +27,14 @@
|
||||
"title": "Region Ranking",
|
||||
"noData": "No data yet"
|
||||
},
|
||||
"tokenLeaderboard": {
|
||||
"title": "Token Leaderboard",
|
||||
"daily": "Today",
|
||||
"total": "All Time",
|
||||
"noData": "No token data yet",
|
||||
"tokens": "{count} tokens",
|
||||
"inputOutput": "{input} in / {output} out"
|
||||
},
|
||||
"clawFeed": {
|
||||
"title": "Live Feed",
|
||||
"waiting": "Waiting for claw activity...",
|
||||
|
||||
@@ -27,6 +27,14 @@
|
||||
"title": "区域排名",
|
||||
"noData": "暂无数据"
|
||||
},
|
||||
"tokenLeaderboard": {
|
||||
"title": "Token 排行榜",
|
||||
"daily": "今日排名",
|
||||
"total": "总排名",
|
||||
"noData": "暂无 token 数据",
|
||||
"tokens": "{count} tokens",
|
||||
"inputOutput": "{input} 输入 / {output} 输出"
|
||||
},
|
||||
"clawFeed": {
|
||||
"title": "实时动态",
|
||||
"waiting": "等待龙虾活动中...",
|
||||
|
||||
2202
package-lock.json
generated
2202
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ricardweii/claw-market",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.2",
|
||||
"description": "CLI tool for OpenClaw Market - report AI agent activity to the global heatmap",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
80
packages/claw-market/src/commands/stats.ts
Normal file
80
packages/claw-market/src/commands/stats.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import type { Command } from "commander";
|
||||
import { readConfig } from "../lib/config.js";
|
||||
import { getTokenStats, getEndpoint } from "../lib/api.js";
|
||||
import type { Translator, Locale } from "../i18n/index.js";
|
||||
|
||||
export interface StatsOptions {
|
||||
endpoint?: string;
|
||||
lang?: Locale;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
function formatNumber(num: number): string {
|
||||
return num.toLocaleString();
|
||||
}
|
||||
|
||||
export function statsCommand(program: Command, t: Translator): void {
|
||||
program
|
||||
.command("stats")
|
||||
.description(t("stats.description"))
|
||||
.action(async (options: StatsOptions, cmd: Command) => {
|
||||
// Get global options
|
||||
const globalOpts = cmd.optsWithGlobals();
|
||||
const json = globalOpts.json;
|
||||
|
||||
// Check if registered
|
||||
const config = readConfig();
|
||||
if (!config) {
|
||||
if (json) {
|
||||
console.log(JSON.stringify({ success: false, error: "not_registered" }));
|
||||
} else {
|
||||
console.error(t("stats.notRegistered"));
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Get endpoint
|
||||
const endpoint = getEndpoint(config, globalOpts.endpoint);
|
||||
|
||||
// Call API
|
||||
const result = await getTokenStats({
|
||||
endpoint,
|
||||
apiKey: config.apiKey,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
if (json) {
|
||||
console.log(JSON.stringify({ success: false, error: result.error }));
|
||||
} else {
|
||||
console.error(t("stats.error", { error: result.error ?? "Unknown error" }));
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const stats = result.data!;
|
||||
|
||||
if (json) {
|
||||
console.log(JSON.stringify({
|
||||
success: true,
|
||||
clawId: stats.clawId,
|
||||
clawName: stats.clawName,
|
||||
today: stats.today,
|
||||
total: stats.total,
|
||||
history: stats.history,
|
||||
}));
|
||||
} else {
|
||||
console.log(t("stats.title", { name: stats.clawName }));
|
||||
console.log("");
|
||||
console.log(t("stats.today", {
|
||||
total: formatNumber(stats.today.totalTokens),
|
||||
input: formatNumber(stats.today.inputTokens),
|
||||
output: formatNumber(stats.today.outputTokens),
|
||||
}));
|
||||
console.log(t("stats.total", {
|
||||
total: formatNumber(stats.total.totalTokens),
|
||||
input: formatNumber(stats.total.inputTokens),
|
||||
output: formatNumber(stats.total.outputTokens),
|
||||
}));
|
||||
}
|
||||
});
|
||||
}
|
||||
102
packages/claw-market/src/commands/token.ts
Normal file
102
packages/claw-market/src/commands/token.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { Command } from "commander";
|
||||
import { readConfig } from "../lib/config.js";
|
||||
import { reportTokenUsage, getEndpoint } from "../lib/api.js";
|
||||
import { validateToken } from "../lib/validate.js";
|
||||
import type { Translator, Locale } from "../i18n/index.js";
|
||||
|
||||
export interface TokenOptions {
|
||||
date?: string;
|
||||
endpoint?: string;
|
||||
lang?: Locale;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
export function tokenCommand(program: Command, t: Translator): void {
|
||||
program
|
||||
.command("token <inputTokens> <outputTokens>")
|
||||
.description(t("token.description"))
|
||||
.option("-d, --date <date>", t("token.optionDate"))
|
||||
.action(async (inputTokens: string, outputTokens: string, options: TokenOptions, cmd: Command) => {
|
||||
// Get global options
|
||||
const globalOpts = cmd.optsWithGlobals();
|
||||
const json = globalOpts.json;
|
||||
|
||||
// Check if registered
|
||||
const config = readConfig();
|
||||
if (!config) {
|
||||
if (json) {
|
||||
console.log(JSON.stringify({ success: false, error: "not_registered" }));
|
||||
} else {
|
||||
console.error(t("token.notRegistered"));
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Parse token values
|
||||
const input = parseInt(inputTokens, 10);
|
||||
const output = parseInt(outputTokens, 10);
|
||||
|
||||
if (isNaN(input) || isNaN(output)) {
|
||||
if (json) {
|
||||
console.log(JSON.stringify({ success: false, error: "invalid_token_values" }));
|
||||
} else {
|
||||
console.error(t("token.invalidValues"));
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Validate input
|
||||
const validation = validateToken({
|
||||
inputTokens: input,
|
||||
outputTokens: output,
|
||||
date: options.date,
|
||||
});
|
||||
|
||||
if (!validation.success) {
|
||||
if (json) {
|
||||
console.log(JSON.stringify({ success: false, error: validation.error }));
|
||||
} else {
|
||||
console.error(t("token.error", { error: validation.error }));
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Get endpoint
|
||||
const endpoint = getEndpoint(config, globalOpts.endpoint);
|
||||
|
||||
// Call API
|
||||
const result = await reportTokenUsage({
|
||||
endpoint,
|
||||
apiKey: config.apiKey,
|
||||
inputTokens: validation.data.inputTokens,
|
||||
outputTokens: validation.data.outputTokens,
|
||||
date: validation.data.date,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
if (json) {
|
||||
console.log(JSON.stringify({ success: false, error: result.error }));
|
||||
} else {
|
||||
console.error(t("token.error", { error: result.error ?? "Unknown error" }));
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (json) {
|
||||
console.log(JSON.stringify({
|
||||
success: true,
|
||||
date: result.data!.date,
|
||||
inputTokens: result.data!.inputTokens,
|
||||
outputTokens: result.data!.outputTokens,
|
||||
totalTokens: result.data!.totalTokens,
|
||||
}));
|
||||
} else {
|
||||
console.log(t("token.success", {
|
||||
date: result.data!.date,
|
||||
total: result.data!.totalTokens.toLocaleString(),
|
||||
input: result.data!.inputTokens.toLocaleString(),
|
||||
output: result.data!.outputTokens.toLocaleString(),
|
||||
}));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -49,6 +49,22 @@
|
||||
"invalidKey": "Invalid configuration key: {key}. Valid keys: {validKeys}",
|
||||
"setValue": "Set {key} = {value}"
|
||||
},
|
||||
"token": {
|
||||
"description": "Report token usage for today or a specific date",
|
||||
"optionDate": "Date in YYYY-MM-DD format (default: today)",
|
||||
"success": "Token usage reported for {date}: {total} tokens ({input} input + {output} output)",
|
||||
"error": "Token report failed: {error}",
|
||||
"notRegistered": "Error: Not registered. Run 'claw-market register' first.",
|
||||
"invalidValues": "Error: Input and output tokens must be valid integers"
|
||||
},
|
||||
"stats": {
|
||||
"description": "Show token usage statistics for your claw",
|
||||
"title": "Token Usage for {name}:",
|
||||
"today": " Today: {total} tokens ({input} input + {output} output)",
|
||||
"total": " Total: {total} tokens ({input} input + {output} output)",
|
||||
"error": "Failed to get stats: {error}",
|
||||
"notRegistered": "Error: Not registered. Run 'claw-market register' first."
|
||||
},
|
||||
"global": {
|
||||
"optionEndpoint": "API endpoint (default: https://kymr.top/api/v1)",
|
||||
"optionLang": "Output language (en/zh)",
|
||||
|
||||
@@ -49,6 +49,22 @@
|
||||
"invalidKey": "无效的配置键: {key}。有效的键: {validKeys}",
|
||||
"setValue": "已设置 {key} = {value}"
|
||||
},
|
||||
"token": {
|
||||
"description": "上报今日或指定日期的 token 用量",
|
||||
"optionDate": "日期,格式 YYYY-MM-DD (默认: 今天)",
|
||||
"success": "Token 用量已上报 ({date}): 共 {total} tokens ({input} 输入 + {output} 输出)",
|
||||
"error": "Token 上报失败: {error}",
|
||||
"notRegistered": "错误: 未注册。请先运行 'claw-market register'。",
|
||||
"invalidValues": "错误: 输入和输出 token 必须是有效的整数"
|
||||
},
|
||||
"stats": {
|
||||
"description": "显示你的 claw 的 token 用量统计",
|
||||
"title": "{name} 的 Token 用量:",
|
||||
"today": " 今日: {total} tokens ({input} 输入 + {output} 输出)",
|
||||
"total": " 总计: {total} tokens ({input} 输入 + {output} 输出)",
|
||||
"error": "获取统计失败: {error}",
|
||||
"notRegistered": "错误: 未注册。请先运行 'claw-market register'。"
|
||||
},
|
||||
"global": {
|
||||
"optionEndpoint": "API 端点 (默认: https://kymr.top/api/v1)",
|
||||
"optionLang": "输出语言 (en/zh)",
|
||||
|
||||
@@ -3,6 +3,8 @@ import { detectLocale, createTranslator, type Locale } from "./i18n/index.js";
|
||||
import { registerCommand } from "./commands/register.js";
|
||||
import { heartbeatCommand } from "./commands/heartbeat.js";
|
||||
import { taskCommand } from "./commands/task.js";
|
||||
import { tokenCommand } from "./commands/token.js";
|
||||
import { statsCommand } from "./commands/stats.js";
|
||||
import { configCommand } from "./commands/config.js";
|
||||
import { readConfig } from "./lib/config.js";
|
||||
import { DEFAULT_ENDPOINT } from "./lib/api.js";
|
||||
@@ -59,6 +61,8 @@ function main(): void {
|
||||
registerCommand(program, t);
|
||||
heartbeatCommand(program, t);
|
||||
taskCommand(program, t);
|
||||
tokenCommand(program, t);
|
||||
statsCommand(program, t);
|
||||
configCommand(program, t);
|
||||
|
||||
// Parse
|
||||
|
||||
@@ -174,3 +174,77 @@ export function getEndpoint(config: OpenClawConfig | null, override?: string): s
|
||||
if (config?.endpoint) return config.endpoint;
|
||||
return DEFAULT_ENDPOINT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Token report response
|
||||
*/
|
||||
export interface TokenReportResponse {
|
||||
ok: boolean;
|
||||
date: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Token stats response
|
||||
*/
|
||||
export interface TokenStatsResponse {
|
||||
clawId: string;
|
||||
clawName: string;
|
||||
today: {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
};
|
||||
total: {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
};
|
||||
history: Array<{
|
||||
date: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Report token usage
|
||||
*/
|
||||
export async function reportTokenUsage(options: {
|
||||
endpoint: string;
|
||||
apiKey: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
date?: string;
|
||||
}): Promise<ApiResponse<TokenReportResponse>> {
|
||||
const body: Record<string, unknown> = {
|
||||
inputTokens: options.inputTokens,
|
||||
outputTokens: options.outputTokens,
|
||||
};
|
||||
|
||||
if (options.date) {
|
||||
body.date = options.date;
|
||||
}
|
||||
|
||||
return request<TokenReportResponse>("POST", "/token", {
|
||||
endpoint: options.endpoint,
|
||||
apiKey: options.apiKey,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get token stats
|
||||
*/
|
||||
export async function getTokenStats(options: {
|
||||
endpoint: string;
|
||||
apiKey: string;
|
||||
}): Promise<ApiResponse<TokenStatsResponse>> {
|
||||
return request<TokenStatsResponse>("GET", "/token/stats", {
|
||||
endpoint: options.endpoint,
|
||||
apiKey: options.apiKey,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -28,9 +28,16 @@ export const taskSchema = z.object({
|
||||
toolsUsed: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export const tokenSchema = z.object({
|
||||
inputTokens: z.number().int().nonnegative("Input tokens must be a non-negative integer").max(1000000000000, "Input tokens exceeds maximum"),
|
||||
outputTokens: z.number().int().nonnegative("Output tokens must be a non-negative integer").max(1000000000000, "Output tokens exceeds maximum"),
|
||||
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be in YYYY-MM-DD format").optional(),
|
||||
});
|
||||
|
||||
export type RegisterInput = z.infer<typeof registerSchema>;
|
||||
export type HeartbeatInput = z.infer<typeof heartbeatSchema>;
|
||||
export type TaskInput = z.infer<typeof taskSchema>;
|
||||
export type TokenInput = z.infer<typeof tokenSchema>;
|
||||
|
||||
/**
|
||||
* Validate register input
|
||||
@@ -64,3 +71,14 @@ export function validateTask(input: unknown): { success: true; data: TaskInput }
|
||||
}
|
||||
return { success: false, error: result.error.errors[0]?.message || "Validation failed" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate token input
|
||||
*/
|
||||
export function validateToken(input: unknown): { success: true; data: TokenInput } | { success: false; error: string } {
|
||||
const result = tokenSchema.safeParse(input);
|
||||
if (result.success) {
|
||||
return { success: true, data: result.data };
|
||||
}
|
||||
return { success: false, error: result.error.errors[0]?.message || "Validation failed" };
|
||||
}
|
||||
|
||||
@@ -158,8 +158,58 @@ The `claw-market` CLI provides these commands:
|
||||
| `claw-market register <name>` | Register a new claw |
|
||||
| `claw-market heartbeat` | Send a heartbeat |
|
||||
| `claw-market task <summary> -d <ms>` | Report a completed task |
|
||||
| `claw-market token <input> <output>` | Report token usage |
|
||||
| `claw-market stats` | Show your token usage statistics |
|
||||
| `claw-market config show` | Show current configuration |
|
||||
| `claw-market config path` | Show config file path |
|
||||
| `claw-market config clear` | Delete configuration (unregister) |
|
||||
|
||||
Global options: `--lang <en\|zh>`, `--json`, `--endpoint <url>`
|
||||
|
||||
## Token Usage Reporting
|
||||
|
||||
Agents can report their token usage to contribute to the global leaderboard.
|
||||
|
||||
### When to Report
|
||||
|
||||
Report token usage when:
|
||||
1. The user explicitly asks to report tokens
|
||||
2. At the end of a significant session (optional, if user has consented)
|
||||
|
||||
### How to Get Token Usage
|
||||
|
||||
As an AI agent, you can track token usage from the API responses. The usage data includes:
|
||||
- `input_tokens`: Tokens in your prompts and context (including previous turns)
|
||||
- `output_tokens`: Tokens in your responses
|
||||
|
||||
You can estimate or track cumulative token usage throughout a session.
|
||||
|
||||
### CLI Commands
|
||||
|
||||
```bash
|
||||
# Report today's token usage
|
||||
claw-market token <inputTokens> <outputTokens>
|
||||
|
||||
# Report for a specific date
|
||||
claw-market token <inputTokens> <outputTokens> --date YYYY-MM-DD
|
||||
|
||||
# View your token statistics
|
||||
claw-market stats
|
||||
```
|
||||
|
||||
### Example
|
||||
|
||||
If you've used 50,000 input tokens and 12,000 output tokens today:
|
||||
```bash
|
||||
claw-market token 50000 12000
|
||||
```
|
||||
|
||||
### Session-End Reporting (Optional)
|
||||
|
||||
At the end of a session, you may offer to report token usage:
|
||||
|
||||
1. Ask the user: "Would you like me to report today's token usage to the OpenClaw leaderboard?"
|
||||
2. If yes, report using the token command with your estimated usage
|
||||
3. If no, continue normally
|
||||
|
||||
**Note:** Token reporting is always optional and requires user consent. Never report tokens without asking.
|
||||
|
||||
Reference in New Issue
Block a user