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:
parent
b504977853
commit
2371e02d57
2 changed files with 298 additions and 3 deletions
56
analyze.js
56
analyze.js
|
|
@ -19,6 +19,7 @@
|
||||||
* node analyze.js --json # JSON output (all sections)
|
* node analyze.js --json # JSON output (all sections)
|
||||||
* 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
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -26,6 +27,7 @@ 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';
|
||||||
|
|
||||||
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'];
|
||||||
|
|
@ -359,7 +361,7 @@ function pruneLogs(dryRun = false) {
|
||||||
|
|
||||||
// ── Report generation ─────────────────────────────────────────────────────────
|
// ── Report generation ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function generateFullReport(entries) {
|
function generateFullReport(entries, xaiSection = null) {
|
||||||
const ts = new Date().toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC');
|
const ts = new Date().toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC');
|
||||||
const width = 56;
|
const width = 56;
|
||||||
const lines = [
|
const lines = [
|
||||||
|
|
@ -452,6 +454,12 @@ function generateFullReport(entries) {
|
||||||
lines.push('No log data found. Run monitor.js to start accumulating data.');
|
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');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -462,12 +470,13 @@ const showBurnRate = args.includes('--burn-rate');
|
||||||
const showWeekly = args.includes('--weekly');
|
const showWeekly = args.includes('--weekly');
|
||||||
const showStagger = args.includes('--stagger');
|
const showStagger = args.includes('--stagger');
|
||||||
const showRotation = args.includes('--rotation');
|
const showRotation = args.includes('--rotation');
|
||||||
|
const showXai = args.includes('--xai');
|
||||||
const isJson = args.includes('--json');
|
const isJson = args.includes('--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 && !isPrune;
|
const showAll = !showBurnRate && !showWeekly && !showStagger && !showRotation && !showXai && !isPrune;
|
||||||
|
|
||||||
if (isPrune) {
|
if (isPrune) {
|
||||||
pruneLogs(isDryRun);
|
pruneLogs(isDryRun);
|
||||||
|
|
@ -476,6 +485,33 @@ if (isPrune) {
|
||||||
|
|
||||||
const entries = loadLogs(providerFilter);
|
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) {
|
if (isJson) {
|
||||||
const burnRates = {};
|
const burnRates = {};
|
||||||
for (const name of TEAMS) {
|
for (const name of TEAMS) {
|
||||||
|
|
@ -494,7 +530,19 @@ if (isJson) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showAll) {
|
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);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -544,3 +592,5 @@ if (showRotation) {
|
||||||
console.log(` ${i + 1}. ${provider.padEnd(16)} ${icon} ${reason}`);
|
console.log(` ${i + 1}. ${provider.padEnd(16)} ${icon} ${reason}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
})();
|
||||||
|
|
|
||||||
245
providers/xai-billing.js
Normal file
245
providers/xai-billing.js
Normal 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');
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue