Fetch OAuth quota from cli-chat-proxy billing, GetGrokCreditsConfig weekly window, and settings plan label so the dashboard matches grok.com usage.
327 lines
9.9 KiB
JavaScript
327 lines
9.9 KiB
JavaScript
/**
|
|
* 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}` };
|
|
}
|
|
}
|