diff --git a/analyze.js b/analyze.js index 6ca92a7..1eb5f99 100644 --- a/analyze.js +++ b/analyze.js @@ -9,8 +9,6 @@ * - Rotation recommendations (rule-based) * - Underspend alerts * - Log hygiene (--prune) - * - Budget JSON for agent consumption (--budget-json) - * - Mission cost attribution (--mission, --mission-window) * * Usage: * node analyze.js # full report @@ -22,9 +20,6 @@ * node analyze.js --provider team-nadja # filter to one provider * node analyze.js --prune [--dry-run] # log hygiene * node analyze.js --xai # xAI spend section only - * node analyze.js --budget-json # agent-consumable budget decision schema - * node analyze.js --mission # mission cost (e.g. bookmarko#1 or commons#13) - * node analyze.js --mission-window # mission cost, manual time range */ import { @@ -32,30 +27,7 @@ import { } from 'fs'; import { homedir } from 'os'; import { join } from 'path'; -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 {}; } -} +import { fetchXaiUsage, fetchXaiInvoicePreview, renderXaiSection } from './providers/xai-billing.js'; const LOG_DIR = join(homedir(), '.logs', 'token-monitor'); const TEAMS = ['team-vigilio', 'team-ludo', 'team-molto', 'team-nadja', 'team-buio']; @@ -491,227 +463,6 @@ function generateFullReport(entries, xaiSection = null) { 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 ────────────────────────────────────────────────────────────────────── const args = process.argv.slice(2); @@ -721,18 +472,11 @@ const showStagger = args.includes('--stagger'); const showRotation = args.includes('--rotation'); const showXai = args.includes('--xai'); const isJson = args.includes('--json'); -const isBudgetJson = args.includes('--budget-json'); const isPrune = args.includes('--prune'); const isDryRun = args.includes('--dry-run'); const providerIdx = args.indexOf('--provider'); const providerFilter = providerIdx !== -1 ? args[providerIdx + 1] : null; -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; +const showAll = !showBurnRate && !showWeekly && !showStagger && !showRotation && !showXai && !isPrune; if (isPrune) { pruneLogs(isDryRun); @@ -741,19 +485,10 @@ if (isPrune) { 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 '); - process.exit(1); - } -} - // ── xAI billing helpers ──────────────────────────────────────────────────── const XAI_MANAGEMENT_KEY = process.env.XAI_MANAGEMENT_KEY || null; const XAI_TEAM_ID = process.env.XAI_TEAM_ID || null; -const config = loadConfig(); async function getXaiSection() { if (!XAI_MANAGEMENT_KEY || !XAI_TEAM_ID) return null; @@ -777,39 +512,6 @@ async function getXaiSection() { // All remaining paths may need xAI data (async), wrap in IIFE (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) { const burnRates = {}; for (const name of TEAMS) { diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 00786d4..0000000 --- a/package-lock.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "token-monitor", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "token-monitor", - "version": "0.1.0", - "engines": { - "node": ">=18" - } - } - } -} diff --git a/providers/anthropic-teams.js b/providers/anthropic-teams.js index ad974cb..3a0991f 100644 --- a/providers/anthropic-teams.js +++ b/providers/anthropic-teams.js @@ -116,25 +116,6 @@ export async function probeTeamsProvider(providerName, baseUrl, apiKey) { }), }); - // HTTP 400 with "extra_usage" policy: Anthropic changed billing April 4 2026. - // Third-party apps (incl. pi) no longer draw from Teams plan limits. - // Rate-limit headers ARE present but the provider is unusable for sessions. - // Detect this before parseTeamsHeaders so we surface a clear status. - if (response.status === 400) { - let bodyText = ''; - try { bodyText = await response.text(); } catch (_) {} - if (bodyText.includes('extra usage') || bodyText.includes('invalid_request_error')) { - // Still parse headers for quota visibility, but override status - const base = parseTeamsHeaders(response.headers, response.status, providerName); - return { - ...base, - status: 'policy_rejected', - policy_message: 'Third-party apps blocked (Anthropic April 2026 billing change)', - severity: 'critical', - }; - } - } - return parseTeamsHeaders(response.headers, response.status, providerName); } catch (err) { return { diff --git a/report.js b/report.js index d3ce6d1..2654bbc 100644 --- a/report.js +++ b/report.js @@ -10,7 +10,6 @@ export function getSeverity(provider) { if (provider.type === 'teams-direct') { if (provider.status === 'rejected') return 'critical'; - if (provider.status === 'policy_rejected') return 'critical'; if (provider.utilization_7d > 0.85) return 'warning'; if (provider.utilization_5h > 0.7) return 'warning'; return 'ok'; @@ -100,8 +99,6 @@ export function generateReport(result) { if (p.type === 'teams-direct') { if (p.status === 'invalid_key') { detail = 'Invalid API key (401)'; - } else if (p.status === 'policy_rejected') { - detail = `POLICY BLOCKED — 7d: ${pct(p.utilization_7d)} | extra-usage billing required`; } else if (p.status === 'rejected') { const resetIn = formatDuration(p.reset_in_seconds); detail = `MAXED — 7d: ${pct(p.utilization_7d)} | resets in ${resetIn}`; diff --git a/tui.js b/tui.js deleted file mode 100644 index ff0fe0a..0000000 --- a/tui.js +++ /dev/null @@ -1,351 +0,0 @@ -#!/usr/bin/env node -/** - * tui.js — Token Monitor live terminal dashboard - * - * Live ANSI dashboard. Refreshes every 60 seconds. - * No external dependencies — pure Node stdlib + project modules. - * - * Usage: - * node tui.js - * - * Keys: - * [r] — force immediate refresh - * [q] — quit cleanly (restores terminal) - * - * Degrades gracefully when XAI_MANAGEMENT_KEY is not set. - */ - -import { spawnSync } from 'child_process'; -import { existsSync, readFileSync } from 'fs'; -import { homedir } from 'os'; -import { join } from 'path'; -import { createRequire } from 'module'; -import { fileURLToPath } from 'url'; -import { dirname } from 'path'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -// ── ANSI helpers ────────────────────────────────────────────────────────────── - -const ESC = '\x1b'; -const RESET = `${ESC}[0m`; -const BOLD = `${ESC}[1m`; -const DIM = `${ESC}[2m`; -const RED = `${ESC}[31m`; -const GREEN = `${ESC}[32m`; -const YELLOW = `${ESC}[33m`; -const CYAN = `${ESC}[36m`; -const WHITE = `${ESC}[37m`; -const HIDE_CURSOR = `${ESC}[?25l`; -const SHOW_CURSOR = `${ESC}[?25h`; -const CURSOR_HOME = `${ESC}[H`; -const CLEAR_SCREEN = `${ESC}[2J`; -const CLEAR_EOL = `${ESC}[K`; - -function color(severity) { - switch (severity) { - case 'ok': return GREEN; - case 'warning': return YELLOW; - case 'critical': return RED; - case 'dormant': return DIM; - default: return DIM; - } -} - -function severityIcon(severity) { - switch (severity) { - case 'ok': return '✓'; - case 'warning': return '!'; - case 'critical': return '✗'; - default: return '~'; - } -} - -function formatDuration(seconds) { - if (seconds == null || isNaN(seconds) || seconds < 0) return '?'; - const h = Math.floor(seconds / 3600); - const m = Math.floor((seconds % 3600) / 60); - if (h > 0) return `${h}h ${m > 0 ? m + 'm' : ''}`.trim(); - if (m > 0) return `${m}m`; - return `${Math.round(seconds)}s`; -} - -function pct(v) { - if (v == null) return ' ?%'; - return `${Math.round(v * 100)}%`; -} - -function bar(fraction, width = 7) { - if (fraction == null) return ' '.repeat(width); - const filled = Math.round(Math.min(1, Math.max(0, fraction)) * width); - const empty = width - filled; - return '█'.repeat(filled) + '░'.repeat(empty); -} - -// ── Data fetching ───────────────────────────────────────────────────────────── - -async function fetchBudgetData() { - try { - const result = spawnSync('node', [join(__dirname, 'analyze.js'), '--budget-json'], { - encoding: 'utf-8', - timeout: 30_000, - env: process.env, - }); - if (result.status !== 0 || !result.stdout) { - return { error: result.stderr?.trim() || 'analyze.js returned no output' }; - } - return JSON.parse(result.stdout); - } catch (err) { - return { error: err.message }; - } -} - -// ── Layout ──────────────────────────────────────────────────────────────────── - -const WIDTH = 75; - -function hline(char = '─', w = WIDTH - 2) { return char.repeat(w); } - -function border(content) { - return `│ ${content}${CLEAR_EOL}`; -} - -function header(ts) { - const tsStr = ts ? new Date(ts).toUTCString().replace(' GMT', ' UTC') : ''; - const title = 'Token Monitor'; - const pad = WIDTH - 4 - title.length - tsStr.length; - return `┌─${BOLD}${CYAN} ${title} ${RESET}${'─'.repeat(Math.max(0, pad))}${DIM}${tsStr}${RESET} ─┐`; -} - -function footer() { - const hint = '[r] refresh [q] quit'; - const pad = WIDTH - 4 - hint.length; - return `└${'─'.repeat(pad > 0 ? pad : 0)}${DIM}${hint}${RESET}─┘`; -} - -function renderAnthropicSection(providers, decision) { - const lines = []; - lines.push(border(`${BOLD}${CYAN}ANTHROPIC TEAMS${RESET}`)); - - const TEAMS = ['team-vigilio', 'team-ludo', 'team-molto', 'team-nadja', 'team-buio']; - - for (const name of TEAMS) { - const p = providers?.[name]; - if (!p) continue; - - const sev = p.severity || 'unknown'; - const c = color(sev); - const icon = severityIcon(sev); - const shortName = name.replace('team-', ''); - - if (sev === 'critical') { - const resetStr = p.reset_in_seconds != null ? `resets ${formatDuration(p.reset_in_seconds)}` : ''; - lines.push(border(` ${c}${icon}${RESET} ${name.padEnd(16)} ${RED}MAXED${RESET} ${DIM}${resetStr}${RESET}`)); - } else if (p.utilization_7d == null && p.utilization_5h == null) { - lines.push(border(` ${c}${icon}${RESET} ${name.padEnd(16)} ${DIM}DORMANT${RESET}`)); - } else if (p.status === 'invalid_key') { - lines.push(border(` ${DIM}~${RESET} ${name.padEnd(16)} ${DIM}invalid key${RESET}`)); - } else { - const util5h = p.utilization_5h != null ? `5h:${pct(p.utilization_5h).padStart(4)}` : ''; - const util7d = p.utilization_7d != null ? `7d:${pct(p.utilization_7d).padStart(4)}` : ''; - const resetStr = p.reset_in_seconds != null ? ` resets ${formatDuration(p.reset_in_seconds)}` : ''; - lines.push(border(` ${c}${icon}${RESET} ${name.padEnd(16)} ${util5h} ${util7d}${DIM}${resetStr}${RESET}`)); - } - } - - return lines; -} - -function renderXaiSection(xai) { - if (!xai || xai.error) return []; - const lines = []; - lines.push(border(`${BOLD}${CYAN}xAI${RESET}`)); - - if (xai.by_key) { - const keys = Object.entries(xai.by_key); - if (keys.length === 0) { - lines.push(border(` ${DIM}(no spend this period)${RESET}`)); - } else { - const maxSpend = Math.max(...keys.map(([, v]) => v.spend_usd)); - for (const [keyName, data] of keys) { - const fraction = maxSpend > 0 ? data.spend_usd / maxSpend : 0; - const barStr = bar(fraction, 7); - const spendStr = `$${data.spend_usd.toFixed(2)}`.padStart(8); - lines.push(border(` ${keyName.padEnd(20)} ${CYAN}${spendStr}${RESET} ${DIM}${barStr}${RESET}`)); - } - } - } - - if (xai.total_usd != null && xai.prepaid_remaining_usd != null) { - lines.push(border(` ${hline('─', 44)}`)); - const remaining = xai.prepaid_remaining_usd; - const remainColor = remaining < 5 ? RED : remaining < 15 ? YELLOW : GREEN; - lines.push(border(` ${DIM}Prepaid left:${RESET} ${remainColor}$${remaining.toFixed(2)}${RESET} ${DIM}period: ${xai.period || '?'}${RESET}`)); - } - - return lines; -} - -function renderDecision(decision, lastProbe) { - const lines = []; - lines.push(border('')); - - if (decision?.recommended_provider) { - lines.push(border(` ${GREEN}→ USE:${RESET} ${BOLD}${decision.recommended_provider}${RESET}`)); - } - if (decision?.avoid?.length > 0) { - lines.push(border(` ${RED}→ AVOID:${RESET} ${decision.avoid.join(', ')}`)); - } - - const probeStr = lastProbe ? `Last probe: ${new Date(lastProbe).toUTCString().slice(17, 25)} UTC` : ''; - if (probeStr) { - lines.push(border(` ${DIM}${probeStr}${RESET}`)); - } - - return lines; -} - -// ── Render full screen ──────────────────────────────────────────────────────── - -function render(data, status) { - const out = []; - out.push(CURSOR_HOME); - - if (data?.error) { - out.push(header(null)); - out.push(border('')); - out.push(border(` ${RED}Error fetching data:${RESET} ${data.error}`)); - out.push(border('')); - out.push(border(` ${DIM}${status}${RESET}`)); - out.push(border('')); - out.push(footer()); - process.stdout.write(out.join('\n') + '\n'); - return; - } - - const hasXai = data?.xai && !data.xai.error && process.env.XAI_MANAGEMENT_KEY; - - out.push(header(data?.ts)); - out.push(border('')); - - // Two-column layout: Anthropic left, xAI right - const anthLines = renderAnthropicSection(data?.providers, data?.budget_decision); - const xaiLines = hasXai ? renderXaiSection(data?.xai) : []; - - // Output sections - const maxRows = Math.max(anthLines.length, xaiLines.length); - - if (hasXai) { - // Render each section separately (full-width) - out.push(border(`${BOLD}${CYAN}ANTHROPIC TEAMS${RESET}${''.padEnd(8)}${BOLD}${CYAN}xAI${RESET}`)); - out.push(border(hline('─', WIDTH - 2))); - // Side-by-side isn't trivial with ANSI — render stacked instead - for (const line of anthLines.slice(1)) out.push(line); - out.push(border('')); - for (const line of xaiLines) out.push(line); - } else { - for (const line of anthLines) out.push(line); - } - - // Decision + last probe - const decisionLines = renderDecision(data?.budget_decision, data?.ts); - for (const line of decisionLines) out.push(line); - - // Alerts - if (data?.alerts?.length > 0) { - out.push(border('')); - out.push(border(` ${YELLOW}${BOLD}Alerts:${RESET}`)); - for (const alert of data.alerts.slice(0, 3)) { - const c = alert.level === 'critical' ? RED : YELLOW; - out.push(border(` ${c}⚠${RESET} ${alert.provider}: ${alert.message}`)); - } - } - - out.push(border('')); - out.push(border(` ${DIM}${status}${RESET}`)); - out.push(border('')); - out.push(footer()); - - process.stdout.write(out.join('\n') + '\n'); -} - -// ── Main loop ───────────────────────────────────────────────────────────────── - -let lastData = null; -let refreshTimer = null; -let isRefreshing = false; - -function statusLine(nextRefreshIn) { - const next = nextRefreshIn > 0 ? `next refresh in ${nextRefreshIn}s` : 'refreshing...'; - return next; -} - -async function refresh() { - if (isRefreshing) return; - isRefreshing = true; - render(lastData, 'refreshing...'); - const data = await fetchBudgetData(); - lastData = data; - isRefreshing = false; - scheduleRefresh(); - render(lastData, statusLine(60)); -} - -let countdown = 60; - -function scheduleRefresh() { - if (refreshTimer) clearInterval(refreshTimer); - countdown = 60; - refreshTimer = setInterval(() => { - countdown--; - if (!isRefreshing) render(lastData, statusLine(countdown)); - if (countdown <= 0) { - clearInterval(refreshTimer); - refresh(); - } - }, 1000); -} - -function cleanup() { - if (refreshTimer) clearInterval(refreshTimer); - process.stdout.write(SHOW_CURSOR); - process.stdout.write('\n'); - if (process.stdin.isTTY) { - process.stdin.setRawMode(false); - } - process.stdin.pause(); -} - -function start() { - // Hide cursor, clear screen - process.stdout.write(HIDE_CURSOR); - process.stdout.write(CLEAR_SCREEN); - - // Keyboard input - if (process.stdin.isTTY) { - process.stdin.setRawMode(true); - } - process.stdin.resume(); - process.stdin.setEncoding('utf-8'); - process.stdin.on('data', (key) => { - if (key === 'q' || key === '\u0003') { // q or Ctrl-C - cleanup(); - process.exit(0); - } - if (key === 'r') { - if (refreshTimer) clearInterval(refreshTimer); - refresh(); - } - }); - - process.on('SIGINT', () => { cleanup(); process.exit(0); }); - process.on('SIGTERM', () => { cleanup(); process.exit(0); }); - - // Initial render placeholder - render({ error: null }, 'initializing...'); - - // First fetch - refresh(); -} - -start();