feat(xai): track SuperGrok weekly limit + API usage quota
Fetch OAuth quota from cli-chat-proxy billing, GetGrokCreditsConfig weekly window, and settings plan label so the dashboard matches grok.com usage.
This commit is contained in:
326
open-sse/services/usage/xai.js
Normal file
326
open-sse/services/usage/xai.js
Normal file
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* xAI (Grok) OAuth usage handler
|
||||
*
|
||||
* SuperGrok quota is split across two upstream surfaces (OAuth only):
|
||||
*
|
||||
* 1) Monthly / API usage allotment (JSON)
|
||||
* GET https://cli-chat-proxy.grok.com/v1/billing
|
||||
* {
|
||||
* "config": {
|
||||
* "monthlyLimit": { "val": 15000 },
|
||||
* "used": { "val": 733 },
|
||||
* "onDemandCap": { "val": 0 },
|
||||
* "billingPeriodStart": "...",
|
||||
* "billingPeriodEnd": "..."
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* 2) Weekly SuperGrok limit (grpc-web protobuf)
|
||||
* POST https://grok.com/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig
|
||||
* Empty request frame; response message1 contains:
|
||||
* usedPercent (float32), window start/end timestamps, nested windows.
|
||||
* This is what the grok.com usage page labels "Weekly limit" / "Resets …".
|
||||
*
|
||||
* Plan label comes from cli-chat-proxy settings:
|
||||
* GET https://cli-chat-proxy.grok.com/v1/settings → subscription_tier_display
|
||||
*
|
||||
* Note: grok.com/rest/rate-limits is a short chat-window (e.g. 2h query count)
|
||||
* behind Cloudflare browser cookies — not usable with pure OAuth bearer.
|
||||
*/
|
||||
|
||||
import { proxyAwareFetch } from "../../utils/proxyFetch.js";
|
||||
import { U, parseResetTime, toFiniteNumber } from "./shared.js";
|
||||
|
||||
// Empty grpc-web request frame: flag(0) + length(0) + no payload.
|
||||
const GRPC_WEB_EMPTY_FRAME = Buffer.from([0x00, 0x00, 0x00, 0x00, 0x00]);
|
||||
|
||||
function moneyVal(wrapper) {
|
||||
if (wrapper == null) return null;
|
||||
if (typeof wrapper === "number") return toFiniteNumber(wrapper, null);
|
||||
if (typeof wrapper === "object" && wrapper.val != null) {
|
||||
return toFiniteNumber(wrapper.val, null);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function authHeaders(accessToken, extra = {}) {
|
||||
return {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function readVarint(buf, offset) {
|
||||
let val = 0;
|
||||
let shift = 0;
|
||||
let i = offset;
|
||||
while (i < buf.length) {
|
||||
const b = buf[i++];
|
||||
val |= (b & 0x7f) << shift;
|
||||
if ((b & 0x80) === 0) return { value: val >>> 0, offset: i };
|
||||
shift += 7;
|
||||
if (shift > 35) break;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal protobuf decoder for GetGrokCreditsConfig.
|
||||
* Only understands varint / fixed32 / fixed64 / length-delimited.
|
||||
*/
|
||||
function parseProtobufFields(buf) {
|
||||
const fields = [];
|
||||
let i = 0;
|
||||
while (i < buf.length) {
|
||||
const key = readVarint(buf, i);
|
||||
if (!key) break;
|
||||
i = key.offset;
|
||||
const field = key.value >>> 3;
|
||||
const wt = key.value & 7;
|
||||
|
||||
if (wt === 0) {
|
||||
const v = readVarint(buf, i);
|
||||
if (!v) break;
|
||||
i = v.offset;
|
||||
fields.push({ field, type: "varint", value: v.value });
|
||||
} else if (wt === 1) {
|
||||
if (i + 8 > buf.length) break;
|
||||
fields.push({ field, type: "fixed64", value: buf.subarray(i, i + 8) });
|
||||
i += 8;
|
||||
} else if (wt === 5) {
|
||||
if (i + 4 > buf.length) break;
|
||||
fields.push({
|
||||
field,
|
||||
type: "fixed32",
|
||||
value: buf.readFloatLE(i),
|
||||
});
|
||||
i += 4;
|
||||
} else if (wt === 2) {
|
||||
const ln = readVarint(buf, i);
|
||||
if (!ln) break;
|
||||
i = ln.offset;
|
||||
if (i + ln.value > buf.length) break;
|
||||
fields.push({
|
||||
field,
|
||||
type: "bytes",
|
||||
value: buf.subarray(i, i + ln.value),
|
||||
});
|
||||
i += ln.value;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
function parseTimestamp(bytes) {
|
||||
if (!bytes || !bytes.length) return null;
|
||||
const fields = parseProtobufFields(bytes);
|
||||
const seconds = fields.find((f) => f.field === 1 && f.type === "varint")?.value;
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) return null;
|
||||
return new Date(seconds * 1000).toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse grpc-web response bytes from GetGrokCreditsConfig.
|
||||
* Returns { usedPercent, resetAt, periodStart } or null.
|
||||
*/
|
||||
export function parseGrokCreditsConfig(raw) {
|
||||
if (!raw || !raw.length) return null;
|
||||
const buf = Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
|
||||
|
||||
// grpc-web data frame: 1-byte flag + 4-byte big-endian length + message
|
||||
if (buf.length < 5) return null;
|
||||
const flag = buf[0];
|
||||
// Data frames have flag 0; ignore trailer frames (flag 0x80).
|
||||
if (flag !== 0) return null;
|
||||
const msgLen = buf.readUInt32BE(1);
|
||||
if (msgLen <= 0 || 5 + msgLen > buf.length) return null;
|
||||
const msg = buf.subarray(5, 5 + msgLen);
|
||||
|
||||
// Response is typically { 1: CreditsConfig }
|
||||
const top = parseProtobufFields(msg);
|
||||
const configBytes = top.find((f) => f.field === 1 && f.type === "bytes")?.value || msg;
|
||||
const fields = parseProtobufFields(configBytes);
|
||||
|
||||
const usedPercentRaw = fields.find((f) => f.field === 1 && f.type === "fixed32")?.value;
|
||||
const periodStart = parseTimestamp(fields.find((f) => f.field === 4 && f.type === "bytes")?.value);
|
||||
const periodEnd = parseTimestamp(fields.find((f) => f.field === 5 && f.type === "bytes")?.value);
|
||||
|
||||
if (usedPercentRaw == null || !Number.isFinite(usedPercentRaw)) return null;
|
||||
|
||||
const usedPercent = Math.max(0, Math.min(100, usedPercentRaw));
|
||||
return {
|
||||
usedPercent,
|
||||
remainingPercent: Math.max(0, 100 - usedPercent),
|
||||
periodStart,
|
||||
resetAt: periodEnd,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchBilling(accessToken, billingUrl, proxyOptions) {
|
||||
const response = await proxyAwareFetch(billingUrl, {
|
||||
method: "GET",
|
||||
headers: authHeaders(accessToken, { Accept: "application/json" }),
|
||||
}, proxyOptions);
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { error: "auth", status: response.status };
|
||||
}
|
||||
if (!response.ok) {
|
||||
return { error: "http", status: response.status };
|
||||
}
|
||||
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!data || typeof data !== "object") {
|
||||
return { error: "json" };
|
||||
}
|
||||
return { data };
|
||||
}
|
||||
|
||||
async function fetchWeeklyCredits(accessToken, creditsUrl, proxyOptions) {
|
||||
if (!creditsUrl) return null;
|
||||
try {
|
||||
const response = await proxyAwareFetch(creditsUrl, {
|
||||
method: "POST",
|
||||
headers: authHeaders(accessToken, {
|
||||
"Content-Type": "application/grpc-web+proto",
|
||||
"x-grpc-web": "1",
|
||||
"x-user-agent": "connect-es/2.1.1",
|
||||
Accept: "*/*",
|
||||
Origin: "https://grok.com",
|
||||
Referer: "https://grok.com/?_s=usage",
|
||||
}),
|
||||
body: GRPC_WEB_EMPTY_FRAME,
|
||||
}, proxyOptions);
|
||||
|
||||
if (!response.ok) return null;
|
||||
const ab = await response.arrayBuffer();
|
||||
return parseGrokCreditsConfig(Buffer.from(ab));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPlanLabel(accessToken, settingsUrl, proxyOptions) {
|
||||
if (!settingsUrl) return null;
|
||||
try {
|
||||
const response = await proxyAwareFetch(settingsUrl, {
|
||||
method: "GET",
|
||||
headers: authHeaders(accessToken, { Accept: "application/json" }),
|
||||
}, proxyOptions);
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json().catch(() => null);
|
||||
const label = data?.subscription_tier_display;
|
||||
return typeof label === "string" && label.trim() ? label.trim() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} accessToken - xAI OAuth access token
|
||||
* @param {object|null} proxyOptions
|
||||
*/
|
||||
export async function getXaiUsage(accessToken, proxyOptions = null) {
|
||||
if (!accessToken) {
|
||||
return { message: "xAI usage unavailable: no access token. Re-authorize the connection." };
|
||||
}
|
||||
|
||||
const cfg = U("xai") || {};
|
||||
const billingUrl = cfg.url;
|
||||
if (!billingUrl) {
|
||||
return { message: "xAI usage endpoint is not configured." };
|
||||
}
|
||||
|
||||
try {
|
||||
const [billingResult, weekly, planLabel] = await Promise.all([
|
||||
fetchBilling(accessToken, billingUrl, proxyOptions),
|
||||
fetchWeeklyCredits(accessToken, cfg.creditsUrl, proxyOptions),
|
||||
fetchPlanLabel(accessToken, cfg.settingsUrl, proxyOptions),
|
||||
]);
|
||||
|
||||
if (billingResult.error === "auth") {
|
||||
return { message: "xAI OAuth token expired or unauthorized. Please re-authorize." };
|
||||
}
|
||||
|
||||
const quotas = {};
|
||||
let periodStart = null;
|
||||
let periodEnd = null;
|
||||
let onDemandCap = 0;
|
||||
|
||||
if (billingResult.data) {
|
||||
const config =
|
||||
billingResult.data.config && typeof billingResult.data.config === "object"
|
||||
? billingResult.data.config
|
||||
: billingResult.data;
|
||||
const monthlyLimit = moneyVal(config.monthlyLimit);
|
||||
const used = moneyVal(config.used);
|
||||
onDemandCap = moneyVal(config.onDemandCap) ?? 0;
|
||||
periodEnd = parseResetTime(config.billingPeriodEnd);
|
||||
periodStart = parseResetTime(config.billingPeriodStart);
|
||||
|
||||
// Absolute credit counts — do NOT put remaining credits on `remaining`
|
||||
// (QuotaTable treats remaining as a 0-100 percentage; same pitfall as Qoder).
|
||||
if (monthlyLimit != null && monthlyLimit > 0) {
|
||||
const usedSafe = Math.max(0, used ?? 0);
|
||||
quotas.api_usage = {
|
||||
used: usedSafe,
|
||||
total: monthlyLimit,
|
||||
remainingCredits: Math.max(0, monthlyLimit - usedSafe),
|
||||
unit: "credits",
|
||||
resetAt: periodEnd,
|
||||
unlimited: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (onDemandCap > 0) {
|
||||
quotas.on_demand = {
|
||||
used: 0,
|
||||
total: onDemandCap,
|
||||
remainingCredits: onDemandCap,
|
||||
unit: "credits",
|
||||
resetAt: periodEnd,
|
||||
unlimited: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Weekly SuperGrok window — percentage-based like Claude/Codex windows.
|
||||
if (weekly) {
|
||||
quotas.weekly = {
|
||||
used: weekly.usedPercent,
|
||||
total: 100,
|
||||
remaining: weekly.remainingPercent,
|
||||
remainingPercentage: weekly.remainingPercent,
|
||||
resetAt: weekly.resetAt || null,
|
||||
unlimited: false,
|
||||
};
|
||||
if (!periodStart && weekly.periodStart) periodStart = weekly.periodStart;
|
||||
}
|
||||
|
||||
if (Object.keys(quotas).length === 0) {
|
||||
const statusHint =
|
||||
billingResult.error === "http"
|
||||
? ` Billing API temporarily unavailable (${billingResult.status}).`
|
||||
: "";
|
||||
return {
|
||||
plan: planLabel || "xAI",
|
||||
message: `xAI connected. No quota allotment reported for this account.${statusHint}`,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
quotas: {},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
plan: planLabel || "xAI",
|
||||
periodStart,
|
||||
periodEnd,
|
||||
onDemandCap,
|
||||
quotas,
|
||||
};
|
||||
} catch (error) {
|
||||
return { message: `xAI connected. Unable to fetch billing: ${error.message}` };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user