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

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');
}