feat: xAI Management API billing module (token-monitor#2)

- providers/xai-billing.js: fetchXaiUsage, fetchXaiInvoicePreview,
  aggregateByKey, renderXaiSection
- analyze.js: --xai flag for standalone view; xAI section appended
  to full report when XAI_MANAGEMENT_KEY is set
- Verified against live API: per-key spend by unit type, prepaid
  balance, current billing period total
- Usage endpoint: POST /v1/billing/teams/{id}/usage (analyticsRequest)
- Invoice endpoint: GET /v1/billing/teams/{id}/postpaid/invoice/preview
This commit is contained in:
Vigilio Desto 2026-04-06 08:19:27 +00:00
parent b504977853
commit 2371e02d57
Signed by: vigilio
GPG key ID: 159D6AD58C8E55E9
2 changed files with 298 additions and 3 deletions

View file

@ -19,6 +19,7 @@
* node analyze.js --json # JSON output (all sections)
* 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
*/
import {
@ -26,6 +27,7 @@ import {
} from 'fs';
import { homedir } from 'os';
import { join } from 'path';
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'];
@ -359,7 +361,7 @@ function pruneLogs(dryRun = false) {
// ── Report generation ─────────────────────────────────────────────────────────
function generateFullReport(entries) {
function generateFullReport(entries, xaiSection = null) {
const ts = new Date().toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC');
const width = 56;
const lines = [
@ -452,6 +454,12 @@ function generateFullReport(entries) {
lines.push('No log data found. Run monitor.js to start accumulating data.');
}
// ── xAI spend (optional — only when management key is available)
if (xaiSection) {
lines.push(xaiSection);
lines.push('');
}
return lines.join('\n');
}
@ -462,12 +470,13 @@ const showBurnRate = args.includes('--burn-rate');
const showWeekly = args.includes('--weekly');
const showStagger = args.includes('--stagger');
const showRotation = args.includes('--rotation');
const showXai = args.includes('--xai');
const isJson = args.includes('--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 showAll = !showBurnRate && !showWeekly && !showStagger && !showRotation && !isPrune;
const showAll = !showBurnRate && !showWeekly && !showStagger && !showRotation && !showXai && !isPrune;
if (isPrune) {
pruneLogs(isDryRun);
@ -476,6 +485,33 @@ if (isPrune) {
const entries = loadLogs(providerFilter);
// ── xAI billing helpers ────────────────────────────────────────────────────
const XAI_MANAGEMENT_KEY = process.env.XAI_MANAGEMENT_KEY || null;
const XAI_TEAM_ID = process.env.XAI_TEAM_ID || null;
async function getXaiSection() {
if (!XAI_MANAGEMENT_KEY || !XAI_TEAM_ID) return null;
try {
const now = new Date();
// Current billing period: 1st of this month through today
const startDate = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
const endDate = now;
const startDateStr = startDate.toISOString().slice(0, 10);
const endDateStr = endDate.toISOString().slice(0, 10);
const [usageRows, preview] = await Promise.all([
fetchXaiUsage({ managementKey: XAI_MANAGEMENT_KEY, teamId: XAI_TEAM_ID, startDate, endDate }),
fetchXaiInvoicePreview({ managementKey: XAI_MANAGEMENT_KEY, teamId: XAI_TEAM_ID }),
]);
return renderXaiSection({ usageRows, preview, startDateStr, endDateStr });
} catch (err) {
return `xAI Spend\n (error: ${err.message})`;
}
}
// All remaining paths may need xAI data (async), wrap in IIFE
(async () => {
if (isJson) {
const burnRates = {};
for (const name of TEAMS) {
@ -494,7 +530,19 @@ if (isJson) {
}
if (showAll) {
console.log(generateFullReport(entries));
const xaiSection = await getXaiSection();
console.log(generateFullReport(entries, xaiSection));
process.exit(0);
}
if (showXai) {
const xaiSection = await getXaiSection();
if (xaiSection) {
console.log(xaiSection);
} else {
console.log('xAI Spend');
console.log(' (XAI_MANAGEMENT_KEY or XAI_TEAM_ID not set — source ~/.secrets/keys.env)');
}
process.exit(0);
}
@ -544,3 +592,5 @@ if (showRotation) {
console.log(` ${i + 1}. ${provider.padEnd(16)} ${icon} ${reason}`);
});
}
})();

245
providers/xai-billing.js Normal file
View file

@ -0,0 +1,245 @@
/**
* xai-billing.js xAI Management API billing module
*
* Wraps the xAI Management API (`https://management-api.x.ai`) for programmatic
* access to spend data, invoice previews, and prepaid balance.
*
* Environment variables required:
* XAI_MANAGEMENT_KEY management key (separate from API keys)
* XAI_TEAM_ID team UUID from console.x.ai Settings Team
*
* Both are exported in ~/.secrets/keys.env.
*
* API reference: xai-docs page "developers/rest-api-reference/management/billing"
*
* Unit conventions:
* - Usage `usd` aggregation returns dollars (floating point)
* - Invoice amounts (totalWithCorr, prepaidCredits) are in USD cents (integer strings)
*/
const MANAGEMENT_BASE = 'https://management-api.x.ai';
/**
* Format a Date as "YYYY-MM-DD HH:MM:SS" (required by analytics timeRange).
* @param {Date} d
* @returns {string}
*/
function toAnalyticsDate(d) {
return d.toISOString().replace('T', ' ').replace(/\.\d+Z$/, '');
}
/**
* Parse a key label like "xai-vigilio (xai-...3Xxp)" "xai-vigilio".
* Falls back to the raw label if pattern doesn't match.
* @param {string} label
* @returns {string}
*/
function extractKeyName(label) {
const m = label.match(/^([^\s(]+)/);
return m ? m[1] : label;
}
/**
* Fetch usage data for a date range, grouped by API key and unit type.
*
* @param {Object} opts
* @param {string} opts.managementKey
* @param {string} opts.teamId
* @param {Date} opts.startDate inclusive start (will be floored to midnight UTC)
* @param {Date} opts.endDate inclusive end (will be ceiled to 23:59:59 UTC)
* @returns {Promise<Array<{keyId, keyName, unitType, usd}>>} sorted by keyName then unitType
*/
export async function fetchXaiUsage({ managementKey, teamId, startDate, endDate }) {
if (!managementKey || !teamId) {
return [];
}
// Build start/end in YYYY-MM-DD HH:MM:SS format (Etc/GMT timezone)
const start = new Date(startDate);
start.setUTCHours(0, 0, 0, 0);
const end = new Date(endDate);
end.setUTCHours(23, 59, 59, 0);
const body = {
analyticsRequest: {
timeRange: {
startTime: toAnalyticsDate(start),
endTime: toAnalyticsDate(end),
timezone: 'Etc/GMT',
},
timeUnit: 'TIME_UNIT_NONE',
values: [{ name: 'usd', aggregation: 'AGGREGATION_SUM' }],
groupBy: ['api_key_id', 'unit_type'],
filters: [],
},
};
const response = await fetch(
`${MANAGEMENT_BASE}/v1/billing/teams/${teamId}/usage`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${managementKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
}
);
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`xAI usage API ${response.status}: ${text}`);
}
const data = await response.json();
const results = [];
for (const series of data.timeSeries || []) {
const [keyId, unitType] = series.group || [];
const [keyLabel] = series.groupLabels || [];
const usd = series.dataPoints?.[0]?.values?.[0] ?? 0;
if (usd === 0) continue;
results.push({
keyId,
keyName: extractKeyName(keyLabel || keyId),
unitType: unitType || 'unknown',
usd,
});
}
// Sort by keyName, then unitType
results.sort((a, b) =>
a.keyName.localeCompare(b.keyName) || a.unitType.localeCompare(b.unitType)
);
return results;
}
/**
* Fetch the current billing period invoice preview (postpaid).
*
* Provides the running total for the current month, prepaid credits,
* and how much of those credits have been consumed.
*
* @param {Object} opts
* @param {string} opts.managementKey
* @param {string} opts.teamId
* @returns {Promise<{
* totalCents: number,
* prepaidCreditsCents: number,
* prepaidUsedCents: number,
* billingCycle: { year: number, month: number }
* }|null>}
*/
export async function fetchXaiInvoicePreview({ managementKey, teamId }) {
if (!managementKey || !teamId) return null;
const response = await fetch(
`${MANAGEMENT_BASE}/v1/billing/teams/${teamId}/postpaid/invoice/preview`,
{ headers: { 'Authorization': `Bearer ${managementKey}` } }
);
if (!response.ok) return null;
const data = await response.json();
const inv = data.coreInvoice || {};
return {
// totalWithCorr is the net amount after credits — in USD cents
totalCents: parseInt(inv.totalWithCorr?.val ?? inv.amountAfterVat ?? '0', 10),
// prepaidCredits is negative (money allocated from prepaid pool)
prepaidCreditsCents: Math.abs(parseInt(inv.prepaidCredits?.val ?? '0', 10)),
// prepaidCreditsUsed is negative (how much of that pool was consumed)
prepaidUsedCents: Math.abs(parseInt(inv.prepaidCreditsUsed?.val ?? '0', 10)),
billingCycle: data.billingCycle || null,
};
}
/**
* Aggregate raw usage rows by keyName, summing USD across unit types.
*
* @param {Array} rows output of fetchXaiUsage()
* @returns {Map<string, {total: number, byUnit: Object<string,number>}>}
* keyed by keyName, sorted by total descending
*/
export function aggregateByKey(rows) {
const map = new Map();
for (const { keyName, unitType, usd } of rows) {
if (!map.has(keyName)) map.set(keyName, { total: 0, byUnit: {} });
const entry = map.get(keyName);
entry.total += usd;
entry.byUnit[unitType] = (entry.byUnit[unitType] || 0) + usd;
}
// Sort by total descending
return new Map([...map.entries()].sort((a, b) => b[1].total - a[1].total));
}
/**
* Render an xAI spend section for the analyze.js text report.
*
* @param {Object} opts
* @param {Array} opts.usageRows from fetchXaiUsage()
* @param {Object} opts.preview from fetchXaiInvoicePreview() (or null)
* @param {string} opts.startDateStr human-readable period start
* @param {string} opts.endDateStr human-readable period end
* @returns {string}
*/
export function renderXaiSection({ usageRows, preview, startDateStr, endDateStr }) {
const lines = [];
const period = startDateStr ? ` (${startDateStr} ${endDateStr})` : '';
lines.push(`xAI Spend${period}`);
// Unit type abbreviations for compact display
const UNIT_ABBREV = {
'Cached prompt text tokens': 'cached',
'Prompt text tokens': 'prompt',
'Reasoning tokens': 'reason',
'Reasoning text tokens': 'reason',
'Completion text tokens': 'compl',
'Image generation': 'image',
'Generated image': 'image',
'Batch completion tokens': 'batch',
'Web searches': 'search',
};
const byKey = aggregateByKey(usageRows);
if (byKey.size === 0) {
lines.push(' (no spend data — check XAI_MANAGEMENT_KEY and XAI_TEAM_ID)');
} else {
for (const [keyName, { total, byUnit }] of byKey) {
const totalStr = `$${total.toFixed(2)}`;
const parts = Object.entries(byUnit)
.filter(([, v]) => v > 0.001)
.sort((a, b) => b[1] - a[1])
.map(([unit, v]) => `${UNIT_ABBREV[unit] || unit}: $${v.toFixed(2)}`);
const detail = parts.length > 0 ? ` [${parts.join(' ')}]` : '';
lines.push(` ${keyName.padEnd(20)} ${totalStr.padStart(8)}${detail}`);
}
}
// Grand total from usage rows
const grandTotal = [...byKey.values()].reduce((s, e) => s + e.total, 0);
if (byKey.size > 1) {
lines.push(` ${'total'.padEnd(20)} ${('$' + grandTotal.toFixed(2)).padStart(8)}`);
}
// Invoice preview: prepaid balance
if (preview) {
const remaining = (preview.prepaidCreditsCents - preview.prepaidUsedCents) / 100;
const used = preview.prepaidUsedCents / 100;
const total = preview.prepaidCreditsCents / 100;
if (total > 0) {
lines.push(` prepaid credit: $${remaining.toFixed(2)} remaining ($${used.toFixed(2)} of $${total.toFixed(2)} used this period)`);
}
const cycle = preview.billingCycle;
if (cycle) {
lines.push(` billing cycle: ${cycle.year}-${String(cycle.month).padStart(2, '0')}`);
}
}
return lines.join('\n');
}