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:
@@ -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" };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user