Budget Intelligence & TUI — programmatic spend data, live dashboard, mission cost tracking #5
1 changed files with 300 additions and 2 deletions
302
analyze.js
302
analyze.js
|
|
@ -9,6 +9,8 @@
|
||||||
* - Rotation recommendations (rule-based)
|
* - Rotation recommendations (rule-based)
|
||||||
* - Underspend alerts
|
* - Underspend alerts
|
||||||
* - Log hygiene (--prune)
|
* - Log hygiene (--prune)
|
||||||
|
* - Budget JSON for agent consumption (--budget-json)
|
||||||
|
* - Mission cost attribution (--mission, --mission-window)
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* node analyze.js # full report
|
* node analyze.js # full report
|
||||||
|
|
@ -20,6 +22,9 @@
|
||||||
* node analyze.js --provider team-nadja # filter to one provider
|
* node analyze.js --provider team-nadja # filter to one provider
|
||||||
* node analyze.js --prune [--dry-run] # log hygiene
|
* node analyze.js --prune [--dry-run] # log hygiene
|
||||||
* node analyze.js --xai # xAI spend section only
|
* node analyze.js --xai # xAI spend section only
|
||||||
|
* node analyze.js --budget-json # agent-consumable budget decision schema
|
||||||
|
* node analyze.js --mission <ref> # mission cost (e.g. bookmarko#1 or commons#13)
|
||||||
|
* node analyze.js --mission-window <iso-start> <iso-end> # mission cost, manual time range
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -27,7 +32,30 @@ import {
|
||||||
} from 'fs';
|
} from 'fs';
|
||||||
import { homedir } from 'os';
|
import { homedir } from 'os';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { fetchXaiUsage, fetchXaiInvoicePreview, renderXaiSection } from './providers/xai-billing.js';
|
import { fetchXaiUsage, fetchXaiInvoicePreview, renderXaiSection, aggregateByKey } from './providers/xai-billing.js';
|
||||||
|
|
||||||
|
const CONFIG_DIR = join(homedir(), '.config', 'token-monitor');
|
||||||
|
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
||||||
|
const MISSION_REPOS_FILE = join(CONFIG_DIR, 'mission-repos.json');
|
||||||
|
const FORGEJO_URL = process.env.FORGEJO_URL || 'http://localhost:3001';
|
||||||
|
const FORGEJO_TOKEN = process.env.FORGEJO_TOKEN || null;
|
||||||
|
|
||||||
|
// ── Config loading ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function loadConfig() {
|
||||||
|
const defaults = { weekly_seat_usd: 7.50, seats_per_team: 1 };
|
||||||
|
if (!existsSync(CONFIG_FILE)) return defaults;
|
||||||
|
try {
|
||||||
|
return { ...defaults, ...JSON.parse(readFileSync(CONFIG_FILE, 'utf-8')) };
|
||||||
|
} catch {
|
||||||
|
return defaults;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadMissionRepos() {
|
||||||
|
if (!existsSync(MISSION_REPOS_FILE)) return {};
|
||||||
|
try { return JSON.parse(readFileSync(MISSION_REPOS_FILE, 'utf-8')); } catch { return {}; }
|
||||||
|
}
|
||||||
|
|
||||||
const LOG_DIR = join(homedir(), '.logs', 'token-monitor');
|
const LOG_DIR = join(homedir(), '.logs', 'token-monitor');
|
||||||
const TEAMS = ['team-vigilio', 'team-ludo', 'team-molto', 'team-nadja', 'team-buio'];
|
const TEAMS = ['team-vigilio', 'team-ludo', 'team-molto', 'team-nadja', 'team-buio'];
|
||||||
|
|
@ -463,6 +491,227 @@ function generateFullReport(entries, xaiSection = null) {
|
||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Budget JSON (Objective 1) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function buildBudgetJson(entries, config) {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const latest = latestPerProvider(entries, 'teams-direct');
|
||||||
|
const ranked = rotationRank(entries);
|
||||||
|
const weeklyUsd = config.weekly_seat_usd;
|
||||||
|
|
||||||
|
// budget_decision
|
||||||
|
const usable = ranked.filter(r => r.severity !== 'critical' && r.severity !== 'unknown');
|
||||||
|
const avoid = ranked.filter(r => r.severity === 'critical').map(r => r.provider);
|
||||||
|
const recommended = usable.length > 0 ? usable[0].provider : null;
|
||||||
|
const avoidReasons = ranked
|
||||||
|
.filter(r => avoid.includes(r.provider))
|
||||||
|
.map(r => r.provider + ': ' + r.reason)
|
||||||
|
.join('; ');
|
||||||
|
const recommendReason = recommended
|
||||||
|
? `${recommended} at ${pct((latest[recommended]?.p?.utilization_7d) || 0)} 7d utilization`
|
||||||
|
: 'no healthy provider found';
|
||||||
|
const avoidReason = avoidReasons || 'none maxed';
|
||||||
|
const reason = avoid.length > 0
|
||||||
|
? `${avoidReason}; ${recommendReason}`
|
||||||
|
: recommendReason;
|
||||||
|
|
||||||
|
// providers
|
||||||
|
const providers = {};
|
||||||
|
for (const name of TEAMS) {
|
||||||
|
const rec = latest[name];
|
||||||
|
if (!rec) continue;
|
||||||
|
const p = rec.p;
|
||||||
|
const util7d = p.utilization_7d ?? null;
|
||||||
|
const headroom = util7d != null ? Math.round((1 - util7d) * 100) : null;
|
||||||
|
providers[name] = {
|
||||||
|
status: p.status || 'unknown',
|
||||||
|
utilization_5h: p.utilization_5h ?? null,
|
||||||
|
utilization_7d: util7d,
|
||||||
|
headroom_7d_pct: headroom,
|
||||||
|
estimated_weekly_budget_usd: weeklyUsd,
|
||||||
|
estimated_spend_this_week_usd: util7d != null ? parseFloat((util7d * weeklyUsd).toFixed(2)) : null,
|
||||||
|
reset_in_seconds: p.reset_in_seconds ?? null,
|
||||||
|
severity: ranked.find(r => r.provider === name)?.severity || 'unknown',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// alerts
|
||||||
|
const alerts = [];
|
||||||
|
for (const { provider, severity, reason: r } of ranked) {
|
||||||
|
if (severity === 'critical') {
|
||||||
|
const p = latest[provider]?.p;
|
||||||
|
const resetStr = p?.reset_in_seconds != null ? ` — resets in ${formatDuration(p.reset_in_seconds)}` : '';
|
||||||
|
alerts.push({ level: 'critical', provider, message: `maxed${resetStr}` });
|
||||||
|
} else if (severity === 'warning') {
|
||||||
|
alerts.push({ level: 'warning', provider, message: r });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// xAI section
|
||||||
|
let xai = null;
|
||||||
|
if (XAI_MANAGEMENT_KEY && XAI_TEAM_ID) {
|
||||||
|
try {
|
||||||
|
const now2 = new Date();
|
||||||
|
const startDate = new Date(Date.UTC(now2.getUTCFullYear(), now2.getUTCMonth(), 1));
|
||||||
|
const [usageRows, preview] = await Promise.all([
|
||||||
|
fetchXaiUsage({ managementKey: XAI_MANAGEMENT_KEY, teamId: XAI_TEAM_ID, startDate, endDate: now2 }),
|
||||||
|
fetchXaiInvoicePreview({ managementKey: XAI_MANAGEMENT_KEY, teamId: XAI_TEAM_ID }),
|
||||||
|
]);
|
||||||
|
const byKey = aggregateByKey(usageRows);
|
||||||
|
const period = `${now2.getUTCFullYear()}-${String(now2.getUTCMonth() + 1).padStart(2, '0')}`;
|
||||||
|
const byKeyObj = {};
|
||||||
|
const CACHED_UNIT = 'Cached prompt text tokens';
|
||||||
|
const PROMPT_UNIT = 'Prompt text tokens';
|
||||||
|
for (const [keyName, { total, byUnit }] of byKey) {
|
||||||
|
byKeyObj[keyName] = {
|
||||||
|
spend_usd: parseFloat(total.toFixed(2)),
|
||||||
|
cached_usd: parseFloat((byUnit[CACHED_UNIT] || 0).toFixed(2)),
|
||||||
|
prompt_usd: parseFloat((byUnit[PROMPT_UNIT] || 0).toFixed(2)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const totalUsd = [...byKey.values()].reduce((s, e) => s + e.total, 0);
|
||||||
|
const prepaidRemaining = preview
|
||||||
|
? parseFloat(((preview.prepaidCreditsCents - preview.prepaidUsedCents) / 100).toFixed(2))
|
||||||
|
: null;
|
||||||
|
xai = { period, by_key: byKeyObj, total_usd: parseFloat(totalUsd.toFixed(2)), prepaid_remaining_usd: prepaidRemaining };
|
||||||
|
// xAI alert if prepaid low
|
||||||
|
if (prepaidRemaining != null && prepaidRemaining < 5) {
|
||||||
|
alerts.push({ level: 'warning', provider: 'xai', message: `prepaid balance low: $${prepaidRemaining.toFixed(2)} remaining` });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
xai = { error: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ts: now,
|
||||||
|
budget_decision: { recommended_provider: recommended, avoid, reason },
|
||||||
|
providers,
|
||||||
|
...(xai ? { xai } : {}),
|
||||||
|
alerts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mission cost tracking (Objective 2) ───────────────────────────────────────
|
||||||
|
|
||||||
|
async function resolveMissionWindow(missionRef) {
|
||||||
|
if (!FORGEJO_TOKEN) throw new Error('FORGEJO_TOKEN not set — cannot resolve issue timestamps');
|
||||||
|
const repos = loadMissionRepos();
|
||||||
|
const m = missionRef.match(/^([^#]+)#(\d+)$/);
|
||||||
|
if (!m) throw new Error(`Invalid mission ref: ${missionRef} (expected format: repo#num)`);
|
||||||
|
const [, shortRepo, issueNum] = m;
|
||||||
|
const repoFull = repos[shortRepo];
|
||||||
|
if (!repoFull) throw new Error(`Unknown repo short name: ${shortRepo}. Add it to ${MISSION_REPOS_FILE}`);
|
||||||
|
|
||||||
|
const headers = { 'Authorization': `token ${FORGEJO_TOKEN}`, 'Content-Type': 'application/json' };
|
||||||
|
const issueUrl = `${FORGEJO_URL}/api/v1/repos/${repoFull}/issues/${issueNum}`;
|
||||||
|
const issueResp = await fetch(issueUrl, { headers });
|
||||||
|
if (!issueResp.ok) throw new Error(`Forgejo issue fetch failed: ${issueResp.status}`);
|
||||||
|
const issue = await issueResp.json();
|
||||||
|
|
||||||
|
const commentsUrl = `${FORGEJO_URL}/api/v1/repos/${repoFull}/issues/${issueNum}/comments?limit=50`;
|
||||||
|
const commResp = await fetch(commentsUrl, { headers });
|
||||||
|
const comments = commResp.ok ? await commResp.json() : [];
|
||||||
|
|
||||||
|
const startTs = issue.created_at;
|
||||||
|
const timestamps = [issue.created_at, issue.updated_at, ...comments.map(c => c.updated_at)].filter(Boolean);
|
||||||
|
const endTs = timestamps.sort().pop();
|
||||||
|
|
||||||
|
return {
|
||||||
|
ref: missionRef,
|
||||||
|
repo: repoFull,
|
||||||
|
issue_num: issueNum,
|
||||||
|
title: issue.title,
|
||||||
|
start: startTs,
|
||||||
|
end: endTs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function entriesInWindow(allEntries, startIso, endIso) {
|
||||||
|
const start = new Date(startIso).getTime();
|
||||||
|
const end = new Date(endIso).getTime();
|
||||||
|
return allEntries.filter(e => {
|
||||||
|
const t = new Date(e.ts).getTime();
|
||||||
|
return t >= start && t <= end;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function computeMissionCost(startIso, endIso, missionLabel, config) {
|
||||||
|
const allEntries = loadLogs();
|
||||||
|
const window = entriesInWindow(allEntries, startIso, endIso);
|
||||||
|
const weeklyUsd = config.weekly_seat_usd;
|
||||||
|
|
||||||
|
const startDt = new Date(startIso);
|
||||||
|
const endDt = new Date(endIso);
|
||||||
|
const durationMs = endDt - startDt;
|
||||||
|
const durationH = Math.floor(durationMs / 3_600_000);
|
||||||
|
const durationM = Math.floor((durationMs % 3_600_000) / 60_000);
|
||||||
|
const durationStr = durationH > 0 ? `${durationH}h ${durationM}m` : `${durationM}m`;
|
||||||
|
|
||||||
|
const lines = [];
|
||||||
|
if (missionLabel) lines.push(`Mission Cost: ${missionLabel}`);
|
||||||
|
else lines.push('Mission Cost (time window)');
|
||||||
|
|
||||||
|
const startStr = startIso.slice(0, 16).replace('T', ' ');
|
||||||
|
const endStr = endIso.slice(0, 16).replace('T', ' ');
|
||||||
|
lines.push(` Window: ${startStr} → ${endStr} (${durationStr})`);
|
||||||
|
lines.push(' ' + '─'.repeat(51));
|
||||||
|
|
||||||
|
let totalEstUsd = 0;
|
||||||
|
let totalXaiUsd = 0;
|
||||||
|
|
||||||
|
// Anthropic Teams: utilization delta per provider
|
||||||
|
for (const name of TEAMS) {
|
||||||
|
const pts = window
|
||||||
|
.filter(e => e.providers[name]?.utilization_7d != null)
|
||||||
|
.sort((a, b) => a.ts.localeCompare(b.ts));
|
||||||
|
if (pts.length < 2) continue;
|
||||||
|
const firstUtil = pts[0].providers[name].utilization_7d;
|
||||||
|
const lastUtil = pts[pts.length - 1].providers[name].utilization_7d;
|
||||||
|
const delta = lastUtil - firstUtil;
|
||||||
|
if (Math.abs(delta) < 0.001) continue;
|
||||||
|
const estUsd = delta * weeklyUsd;
|
||||||
|
totalEstUsd += Math.max(0, estUsd);
|
||||||
|
const deltaStr = `${(delta * 100).toFixed(1)}%`;
|
||||||
|
const usdStr = estUsd >= 0 ? `~$${estUsd.toFixed(2)} est.` : `~-$${Math.abs(estUsd).toFixed(2)} (decreased)`;
|
||||||
|
lines.push(` ${name.padEnd(16)} 7d: ${pct(firstUtil)} → ${pct(lastUtil)} (${deltaStr.padStart(5)}) ${usdStr}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalEstUsd === 0 && window.length < 2) {
|
||||||
|
lines.push(' (no utilization data in this time window)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// xAI: exact spend via management API
|
||||||
|
if (XAI_MANAGEMENT_KEY && XAI_TEAM_ID) {
|
||||||
|
try {
|
||||||
|
const usageRows = await fetchXaiUsage({
|
||||||
|
managementKey: XAI_MANAGEMENT_KEY,
|
||||||
|
teamId: XAI_TEAM_ID,
|
||||||
|
startDate: new Date(startIso),
|
||||||
|
endDate: new Date(endIso),
|
||||||
|
});
|
||||||
|
const byKey = aggregateByKey(usageRows);
|
||||||
|
for (const [keyName, { total }] of byKey) {
|
||||||
|
totalXaiUsd += total;
|
||||||
|
lines.push(` ${keyName.padEnd(16)} $${total.toFixed(2)} exact (management API)`);
|
||||||
|
}
|
||||||
|
if (byKey.size === 0) {
|
||||||
|
lines.push(' xAI $0.00 exact (management API)');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
lines.push(` xAI (error: ${err.message})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(' ' + '─'.repeat(51));
|
||||||
|
const totalStr = XAI_MANAGEMENT_KEY
|
||||||
|
? `~$${totalEstUsd.toFixed(2)} (Anthropic) + $${totalXaiUsd.toFixed(2)} (xAI) = ~$${(totalEstUsd + totalXaiUsd).toFixed(2)}`
|
||||||
|
: `~$${totalEstUsd.toFixed(2)} estimated`;
|
||||||
|
lines.push(` Total estimated: ${totalStr}`);
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
// ── Main ──────────────────────────────────────────────────────────────────────
|
// ── Main ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
|
|
@ -472,11 +721,18 @@ const showStagger = args.includes('--stagger');
|
||||||
const showRotation = args.includes('--rotation');
|
const showRotation = args.includes('--rotation');
|
||||||
const showXai = args.includes('--xai');
|
const showXai = args.includes('--xai');
|
||||||
const isJson = args.includes('--json');
|
const isJson = args.includes('--json');
|
||||||
|
const isBudgetJson = args.includes('--budget-json');
|
||||||
const isPrune = args.includes('--prune');
|
const isPrune = args.includes('--prune');
|
||||||
const isDryRun = args.includes('--dry-run');
|
const isDryRun = args.includes('--dry-run');
|
||||||
const providerIdx = args.indexOf('--provider');
|
const providerIdx = args.indexOf('--provider');
|
||||||
const providerFilter = providerIdx !== -1 ? args[providerIdx + 1] : null;
|
const providerFilter = providerIdx !== -1 ? args[providerIdx + 1] : null;
|
||||||
const showAll = !showBurnRate && !showWeekly && !showStagger && !showRotation && !showXai && !isPrune;
|
const missionIdx = args.indexOf('--mission');
|
||||||
|
const missionRef = missionIdx !== -1 ? args[missionIdx + 1] : null;
|
||||||
|
const missionWindowIdx = args.indexOf('--mission-window');
|
||||||
|
const missionWindowStart = missionWindowIdx !== -1 ? args[missionWindowIdx + 1] : null;
|
||||||
|
const missionWindowEnd = missionWindowIdx !== -1 ? args[missionWindowIdx + 2] : null;
|
||||||
|
const showAll = !showBurnRate && !showWeekly && !showStagger && !showRotation && !showXai && !isPrune
|
||||||
|
&& !isBudgetJson && !missionRef && !missionWindowStart;
|
||||||
|
|
||||||
if (isPrune) {
|
if (isPrune) {
|
||||||
pruneLogs(isDryRun);
|
pruneLogs(isDryRun);
|
||||||
|
|
@ -485,10 +741,19 @@ if (isPrune) {
|
||||||
|
|
||||||
const entries = loadLogs(providerFilter);
|
const entries = loadLogs(providerFilter);
|
||||||
|
|
||||||
|
// Early exit for mission-window (no xAI IIFE needed if pure time-window with no key)
|
||||||
|
if (missionWindowStart && !missionRef) {
|
||||||
|
if (!missionWindowEnd) {
|
||||||
|
console.error('Usage: node analyze.js --mission-window <iso-start> <iso-end>');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── xAI billing helpers ────────────────────────────────────────────────────
|
// ── xAI billing helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
const XAI_MANAGEMENT_KEY = process.env.XAI_MANAGEMENT_KEY || null;
|
const XAI_MANAGEMENT_KEY = process.env.XAI_MANAGEMENT_KEY || null;
|
||||||
const XAI_TEAM_ID = process.env.XAI_TEAM_ID || null;
|
const XAI_TEAM_ID = process.env.XAI_TEAM_ID || null;
|
||||||
|
const config = loadConfig();
|
||||||
|
|
||||||
async function getXaiSection() {
|
async function getXaiSection() {
|
||||||
if (!XAI_MANAGEMENT_KEY || !XAI_TEAM_ID) return null;
|
if (!XAI_MANAGEMENT_KEY || !XAI_TEAM_ID) return null;
|
||||||
|
|
@ -512,6 +777,39 @@ async function getXaiSection() {
|
||||||
// All remaining paths may need xAI data (async), wrap in IIFE
|
// All remaining paths may need xAI data (async), wrap in IIFE
|
||||||
(async () => {
|
(async () => {
|
||||||
|
|
||||||
|
if (isBudgetJson) {
|
||||||
|
const result = await buildBudgetJson(entries, config);
|
||||||
|
console.log(JSON.stringify(result, null, 2));
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missionWindowStart && missionWindowEnd && !missionRef) {
|
||||||
|
const label = null;
|
||||||
|
const out = await computeMissionCost(missionWindowStart, missionWindowEnd, label, config);
|
||||||
|
console.log(out);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missionRef) {
|
||||||
|
try {
|
||||||
|
const window = await resolveMissionWindow(missionRef);
|
||||||
|
const label = `${window.title} (${missionRef})`;
|
||||||
|
let startIso = window.start;
|
||||||
|
let endIso = window.end;
|
||||||
|
// --start / --end overrides
|
||||||
|
const startOverrideIdx = args.indexOf('--start');
|
||||||
|
const endOverrideIdx = args.indexOf('--end');
|
||||||
|
if (startOverrideIdx !== -1) startIso = args[startOverrideIdx + 1];
|
||||||
|
if (endOverrideIdx !== -1) endIso = args[endOverrideIdx + 1];
|
||||||
|
const out = await computeMissionCost(startIso, endIso, label, config);
|
||||||
|
console.log(out);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error: ${err.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
if (isJson) {
|
if (isJson) {
|
||||||
const burnRates = {};
|
const burnRates = {};
|
||||||
for (const name of TEAMS) {
|
for (const name of TEAMS) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue