Skip to content

Commit 8b0e555

Browse files
committed
feat: add cost savings dashboard with /stats command and web UI
- Add /stats terminal command showing ASCII usage statistics - Add /dashboard web endpoint with interactive Chart.js charts - Add /stats JSON API for programmatic access - Enhanced UsageEntry to track tier, baselineCost, savings - Dashboard shows: total saved, routing by tier, daily breakdown, top models - Web dashboard auto-refreshes every 30 seconds
1 parent e081c86 commit 8b0e555

File tree

4 files changed

+610
-0
lines changed

4 files changed

+610
-0
lines changed

src/index.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { homedir } from "node:os";
3434
import { join } from "node:path";
3535
import { VERSION } from "./version.js";
3636
import { privateKeyToAccount } from "viem/accounts";
37+
import { getStats, formatStatsAscii } from "./stats.js";
3738

3839
/**
3940
* Detect if we're running in shell completion mode.
@@ -279,6 +280,43 @@ async function startProxyInBackground(api: OpenClawPluginApi): Promise<void> {
279280
api.logger.info(`BlockRun provider active — ${proxy.baseUrl}/v1 (smart routing enabled)`);
280281
}
281282

283+
/**
284+
* /stats command handler for ClawRouter.
285+
* Shows usage statistics and cost savings.
286+
*/
287+
async function createStatsCommand(): Promise<OpenClawPluginCommandDefinition> {
288+
return {
289+
name: "stats",
290+
description: "Show ClawRouter usage statistics and cost savings",
291+
acceptsArgs: true,
292+
requireAuth: false,
293+
handler: async (ctx: PluginCommandContext) => {
294+
const arg = ctx.args?.trim().toLowerCase() || "7";
295+
const days = parseInt(arg, 10) || 7;
296+
297+
try {
298+
const stats = await getStats(Math.min(days, 30)); // Cap at 30 days
299+
const ascii = formatStatsAscii(stats);
300+
301+
return {
302+
text: [
303+
"```",
304+
ascii,
305+
"```",
306+
"",
307+
`View detailed dashboard: http://127.0.0.1:${getProxyPort()}/dashboard`,
308+
].join("\n"),
309+
};
310+
} catch (err) {
311+
return {
312+
text: `Failed to load stats: ${err instanceof Error ? err.message : String(err)}`,
313+
isError: true,
314+
};
315+
}
316+
},
317+
};
318+
}
319+
282320
/**
283321
* /wallet command handler for ClawRouter.
284322
* - /wallet or /wallet status: Show wallet address, balance, and key file location
@@ -438,6 +476,17 @@ const plugin: OpenClawPluginDefinition = {
438476
);
439477
});
440478

479+
// Register /stats command for usage statistics
480+
createStatsCommand()
481+
.then((statsCommand) => {
482+
api.registerCommand(statsCommand);
483+
})
484+
.catch((err) => {
485+
api.logger.warn(
486+
`Failed to register /stats command: ${err instanceof Error ? err.message : String(err)}`,
487+
);
488+
});
489+
441490
// Register a service with stop() for cleanup on gateway shutdown
442491
// This prevents EADDRINUSE when the gateway restarts
443492
api.registerService({
@@ -501,3 +550,5 @@ export {
501550
} from "./errors.js";
502551
export { fetchWithRetry, isRetryable, DEFAULT_RETRY_CONFIG } from "./retry.js";
503552
export type { RetryConfig } from "./retry.js";
553+
export { getStats, formatStatsAscii, generateDashboardHtml } from "./stats.js";
554+
export type { DailyStats, AggregatedStats } from "./stats.js";

src/logger.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,10 @@ import { homedir } from "node:os";
1515
export type UsageEntry = {
1616
timestamp: string;
1717
model: string;
18+
tier: string;
1819
cost: number;
20+
baselineCost: number;
21+
savings: number; // 0-1 percentage
1922
latencyMs: number;
2023
};
2124

src/proxy.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
} from "./router/index.js";
3737
import { BLOCKRUN_MODELS } from "./models.js";
3838
import { logUsage, type UsageEntry } from "./logger.js";
39+
import { getStats, generateDashboardHtml } from "./stats.js";
3940
import { RequestDeduplicator } from "./dedup.js";
4041
import { BalanceMonitor } from "./balance.js";
4142
import { InsufficientFundsError, EmptyWalletError } from "./errors.js";
@@ -411,6 +412,53 @@ export async function startProxy(options: ProxyOptions): Promise<ProxyHandle> {
411412
return;
412413
}
413414

415+
// Dashboard endpoint - serves HTML analytics page
416+
if (req.url === "/dashboard" || req.url?.startsWith("/dashboard?")) {
417+
try {
418+
const url = new URL(req.url, "http://localhost");
419+
const days = parseInt(url.searchParams.get("days") || "7", 10);
420+
const stats = await getStats(Math.min(days, 30));
421+
const html = generateDashboardHtml(stats);
422+
423+
res.writeHead(200, {
424+
"Content-Type": "text/html; charset=utf-8",
425+
"Cache-Control": "no-cache",
426+
});
427+
res.end(html);
428+
} catch (err) {
429+
res.writeHead(500, { "Content-Type": "application/json" });
430+
res.end(
431+
JSON.stringify({
432+
error: `Failed to generate dashboard: ${err instanceof Error ? err.message : String(err)}`,
433+
}),
434+
);
435+
}
436+
return;
437+
}
438+
439+
// Stats API endpoint - returns JSON for programmatic access
440+
if (req.url === "/stats" || req.url?.startsWith("/stats?")) {
441+
try {
442+
const url = new URL(req.url, "http://localhost");
443+
const days = parseInt(url.searchParams.get("days") || "7", 10);
444+
const stats = await getStats(Math.min(days, 30));
445+
446+
res.writeHead(200, {
447+
"Content-Type": "application/json",
448+
"Cache-Control": "no-cache",
449+
});
450+
res.end(JSON.stringify(stats, null, 2));
451+
} catch (err) {
452+
res.writeHead(500, { "Content-Type": "application/json" });
453+
res.end(
454+
JSON.stringify({
455+
error: `Failed to get stats: ${err instanceof Error ? err.message : String(err)}`,
456+
}),
457+
);
458+
}
459+
return;
460+
}
461+
414462
// Only proxy paths starting with /v1
415463
if (!req.url?.startsWith("/v1")) {
416464
res.writeHead(404, { "Content-Type": "application/json" });
@@ -1135,7 +1183,10 @@ async function proxyRequest(
11351183
const entry: UsageEntry = {
11361184
timestamp: new Date().toISOString(),
11371185
model: routingDecision.model,
1186+
tier: routingDecision.tier,
11381187
cost: routingDecision.costEstimate,
1188+
baselineCost: routingDecision.baselineCost,
1189+
savings: routingDecision.savings,
11391190
latencyMs: Date.now() - startTime,
11401191
};
11411192
logUsage(entry).catch(() => {});

0 commit comments

Comments
 (0)