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:
richarjiang
2026-03-15 15:17:10 +08:00
parent 8d094ad5cc
commit 36f10954cf
22 changed files with 3140 additions and 11 deletions

View File

@@ -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 */}

View 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
View 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 }
);
}
}

View 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 }
);
}
}

View 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>
);
}

View 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`);

View File

@@ -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),
]
);

View File

@@ -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}`);
}

View File

@@ -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 };
}

View File

@@ -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>;

View File

@@ -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...",

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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": {

View 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),
}));
}
});
}

View 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(),
}));
}
});
}

View File

@@ -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)",

View File

@@ -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)",

View File

@@ -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

View File

@@ -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,
});
}

View File

@@ -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" };
}

View File

@@ -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.