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 { ActivityTimeline } from "@/components/dashboard/activity-timeline";
|
||||||
import { ClawFeed } from "@/components/dashboard/claw-feed";
|
import { ClawFeed } from "@/components/dashboard/claw-feed";
|
||||||
import { RegionRanking } from "@/components/dashboard/region-ranking";
|
import { RegionRanking } from "@/components/dashboard/region-ranking";
|
||||||
|
import { TokenLeaderboard } from "@/components/dashboard/token-leaderboard";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const WorldMap = dynamic(
|
const WorldMap = dynamic(
|
||||||
@@ -55,6 +56,7 @@ export default function HomePage() {
|
|||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<StatsPanel />
|
<StatsPanel />
|
||||||
<RegionRanking />
|
<RegionRanking />
|
||||||
|
<TokenLeaderboard />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Center - Map/Globe + Timeline */}
|
{/* 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,
|
datetime,
|
||||||
json,
|
json,
|
||||||
index,
|
index,
|
||||||
|
uniqueIndex,
|
||||||
} from "drizzle-orm/mysql-core";
|
} from "drizzle-orm/mysql-core";
|
||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
|
|
||||||
@@ -70,3 +71,21 @@ export const geoCache = mysqlTable("geo_cache", {
|
|||||||
region: varchar("region", { length: 50 }),
|
region: varchar("region", { length: 50 }),
|
||||||
updatedAt: datetime("updated_at").default(sql`NOW()`),
|
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> {
|
export async function publishEvent(event: object): Promise<void> {
|
||||||
await redis.publish(CHANNEL_REALTIME, JSON.stringify(event));
|
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 { type ClassValue, clsx } from "clsx";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { validateApiKey } from "@/lib/auth/api-key";
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs));
|
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";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const MAX_TOKENS = 1_000_000_000_000; // 1 trillion
|
||||||
|
|
||||||
export const registerSchema = z.object({
|
export const registerSchema = z.object({
|
||||||
name: z.string().min(1).max(100),
|
name: z.string().min(1).max(100),
|
||||||
platform: z.string().optional(),
|
platform: z.string().optional(),
|
||||||
@@ -19,6 +21,13 @@ export const taskSchema = z.object({
|
|||||||
toolsUsed: z.array(z.string()).optional(),
|
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 RegisterInput = z.infer<typeof registerSchema>;
|
||||||
export type HeartbeatInput = z.infer<typeof heartbeatSchema>;
|
export type HeartbeatInput = z.infer<typeof heartbeatSchema>;
|
||||||
export type TaskInput = z.infer<typeof taskSchema>;
|
export type TaskInput = z.infer<typeof taskSchema>;
|
||||||
|
export type TokenInput = z.infer<typeof tokenSchema>;
|
||||||
|
|||||||
@@ -27,6 +27,14 @@
|
|||||||
"title": "Region Ranking",
|
"title": "Region Ranking",
|
||||||
"noData": "No data yet"
|
"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": {
|
"clawFeed": {
|
||||||
"title": "Live Feed",
|
"title": "Live Feed",
|
||||||
"waiting": "Waiting for claw activity...",
|
"waiting": "Waiting for claw activity...",
|
||||||
|
|||||||
@@ -27,6 +27,14 @@
|
|||||||
"title": "区域排名",
|
"title": "区域排名",
|
||||||
"noData": "暂无数据"
|
"noData": "暂无数据"
|
||||||
},
|
},
|
||||||
|
"tokenLeaderboard": {
|
||||||
|
"title": "Token 排行榜",
|
||||||
|
"daily": "今日排名",
|
||||||
|
"total": "总排名",
|
||||||
|
"noData": "暂无 token 数据",
|
||||||
|
"tokens": "{count} tokens",
|
||||||
|
"inputOutput": "{input} 输入 / {output} 输出"
|
||||||
|
},
|
||||||
"clawFeed": {
|
"clawFeed": {
|
||||||
"title": "实时动态",
|
"title": "实时动态",
|
||||||
"waiting": "等待龙虾活动中...",
|
"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",
|
"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",
|
"description": "CLI tool for OpenClaw Market - report AI agent activity to the global heatmap",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"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}",
|
"invalidKey": "Invalid configuration key: {key}. Valid keys: {validKeys}",
|
||||||
"setValue": "Set {key} = {value}"
|
"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": {
|
"global": {
|
||||||
"optionEndpoint": "API endpoint (default: https://kymr.top/api/v1)",
|
"optionEndpoint": "API endpoint (default: https://kymr.top/api/v1)",
|
||||||
"optionLang": "Output language (en/zh)",
|
"optionLang": "Output language (en/zh)",
|
||||||
|
|||||||
@@ -49,6 +49,22 @@
|
|||||||
"invalidKey": "无效的配置键: {key}。有效的键: {validKeys}",
|
"invalidKey": "无效的配置键: {key}。有效的键: {validKeys}",
|
||||||
"setValue": "已设置 {key} = {value}"
|
"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": {
|
"global": {
|
||||||
"optionEndpoint": "API 端点 (默认: https://kymr.top/api/v1)",
|
"optionEndpoint": "API 端点 (默认: https://kymr.top/api/v1)",
|
||||||
"optionLang": "输出语言 (en/zh)",
|
"optionLang": "输出语言 (en/zh)",
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { detectLocale, createTranslator, type Locale } from "./i18n/index.js";
|
|||||||
import { registerCommand } from "./commands/register.js";
|
import { registerCommand } from "./commands/register.js";
|
||||||
import { heartbeatCommand } from "./commands/heartbeat.js";
|
import { heartbeatCommand } from "./commands/heartbeat.js";
|
||||||
import { taskCommand } from "./commands/task.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 { configCommand } from "./commands/config.js";
|
||||||
import { readConfig } from "./lib/config.js";
|
import { readConfig } from "./lib/config.js";
|
||||||
import { DEFAULT_ENDPOINT } from "./lib/api.js";
|
import { DEFAULT_ENDPOINT } from "./lib/api.js";
|
||||||
@@ -59,6 +61,8 @@ function main(): void {
|
|||||||
registerCommand(program, t);
|
registerCommand(program, t);
|
||||||
heartbeatCommand(program, t);
|
heartbeatCommand(program, t);
|
||||||
taskCommand(program, t);
|
taskCommand(program, t);
|
||||||
|
tokenCommand(program, t);
|
||||||
|
statsCommand(program, t);
|
||||||
configCommand(program, t);
|
configCommand(program, t);
|
||||||
|
|
||||||
// Parse
|
// Parse
|
||||||
|
|||||||
@@ -174,3 +174,77 @@ export function getEndpoint(config: OpenClawConfig | null, override?: string): s
|
|||||||
if (config?.endpoint) return config.endpoint;
|
if (config?.endpoint) return config.endpoint;
|
||||||
return DEFAULT_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(),
|
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 RegisterInput = z.infer<typeof registerSchema>;
|
||||||
export type HeartbeatInput = z.infer<typeof heartbeatSchema>;
|
export type HeartbeatInput = z.infer<typeof heartbeatSchema>;
|
||||||
export type TaskInput = z.infer<typeof taskSchema>;
|
export type TaskInput = z.infer<typeof taskSchema>;
|
||||||
|
export type TokenInput = z.infer<typeof tokenSchema>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate register input
|
* 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" };
|
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 register <name>` | Register a new claw |
|
||||||
| `claw-market heartbeat` | Send a heartbeat |
|
| `claw-market heartbeat` | Send a heartbeat |
|
||||||
| `claw-market task <summary> -d <ms>` | Report a completed task |
|
| `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 show` | Show current configuration |
|
||||||
| `claw-market config path` | Show config file path |
|
| `claw-market config path` | Show config file path |
|
||||||
| `claw-market config clear` | Delete configuration (unregister) |
|
| `claw-market config clear` | Delete configuration (unregister) |
|
||||||
|
|
||||||
Global options: `--lang <en\|zh>`, `--json`, `--endpoint <url>`
|
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