feat(ui): AI Usage Sacred Valley card + /api/ai-usage proxy (Claude tokens + local model perf)
Summarises the Homelab Monitor (CT300 :8080) into a blackflame card: Claude Code token usage today/week + top model, and OpenClaw/Ollama p50/p95 latency + error rate. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
55
lib/api/routes/ai_usage.js
Normal file
55
lib/api/routes/ai_usage.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Router } from 'express';
|
||||
import { asyncWrap } from '../errors.js';
|
||||
|
||||
// Surfaces the Homelab Monitor (phuryn/claude-usage fork) on CT 300 as a compact
|
||||
// summary for the Sacred Valley "AI Usage" card. The monitor owns the data
|
||||
// (scans ~/.claude/**/*.jsonl + OpenClaw trajectories → usage.db); we just fetch
|
||||
// + condense its API over the LAN.
|
||||
const MON = () => process.env.AI_USAGE_URL || 'http://192.168.1.212:8080';
|
||||
|
||||
// Pure: condense the monitor's /api/data + /api/openclaw into the card summary.
|
||||
export function summarizeUsage(data, openclaw) {
|
||||
const rows = (data && data.daily_by_model) || [];
|
||||
const days = [...new Set(rows.map(r => r.day))].sort();
|
||||
const today = days[days.length - 1] || null;
|
||||
const weekDays = new Set(days.slice(-7));
|
||||
const acc = (pred) => rows.filter(pred).reduce((a, r) => ({
|
||||
input: a.input + (r.input || 0),
|
||||
output: a.output + (r.output || 0),
|
||||
cache: a.cache + (r.cache_read || 0) + (r.cache_creation || 0),
|
||||
turns: a.turns + (r.turns || 0)
|
||||
}), { input: 0, output: 0, cache: 0, turns: 0 });
|
||||
|
||||
const modelTokens = {};
|
||||
for (const r of rows) if (weekDays.has(r.day)) modelTokens[r.model] = (modelTokens[r.model] || 0) + (r.input || 0) + (r.output || 0);
|
||||
const topModel = Object.entries(modelTokens).sort((a, b) => b[1] - a[1])[0]?.[0] || null;
|
||||
|
||||
const byModel = (openclaw && openclaw.summary && openclaw.summary.by_model) || [];
|
||||
const sorted = [...byModel].sort((a, b) => (b.count || 0) - (a.count || 0));
|
||||
const clean = (m) => m.replace(/^ollama\//, '');
|
||||
const local = {
|
||||
runs: byModel.reduce((a, m) => a + (m.count || 0), 0),
|
||||
models: sorted.map(m => ({ model: clean(m.model), count: m.count, p50_ms: m.p50_ms, p95_ms: m.p95_ms, error_rate: m.error_rate })),
|
||||
top: sorted[0] ? { model: clean(sorted[0].model), p50_ms: sorted[0].p50_ms, p95_ms: sorted[0].p95_ms, error_rate: sorted[0].error_rate } : null
|
||||
};
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
generated_at: data?.generated_at || null,
|
||||
today,
|
||||
claude: { today: acc(r => r.day === today), week: acc(r => weekDays.has(r.day)), top_model: topModel },
|
||||
local
|
||||
};
|
||||
}
|
||||
|
||||
export const router = Router();
|
||||
|
||||
router.get('/', asyncWrap(async (_req, res) => {
|
||||
const base = MON();
|
||||
const [data, openclaw] = await Promise.all([
|
||||
fetch(`${base}/api/data`, { signal: AbortSignal.timeout(5000) }).then(r => r.ok ? r.json() : null).catch(() => null),
|
||||
fetch(`${base}/api/openclaw`, { signal: AbortSignal.timeout(5000) }).then(r => r.ok ? r.json() : null).catch(() => null)
|
||||
]);
|
||||
if (!data) return res.json({ ok: false, error: 'monitor unreachable', url: base });
|
||||
res.json(summarizeUsage(data, openclaw));
|
||||
}));
|
||||
Reference in New Issue
Block a user