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

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

View File

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

View File

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

View File

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

View File

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